Sign in

    kiln

    Well-posed problem generation with machine-checkable answer certificates

    education
    assessment
    constraint
    verification
    generator
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    6 hours ago
    Downloads
    5

    #kiln · 铸题

    带适定性保证的可验证命题引擎。 把「这道题应该长什么样」写成约束,反解出题目,并让每道题自带可机器复核的答案凭证。

    用 MoonBit 写,零浮点,纯确定性。同一份 schema、同一个 seed,在 wasm-gc / js 两个后端产出逐字节相同的卷子(已用黄金文本验证;native 因本机无 C 工具链,交由 CI 校验)。

    candidates 171 (rejected 0, duplicates 0) unique 171 levels L1:18 L2:29 L3:56 L4:38 L5:30 reverify 171 ok, 0 failed


    #为什么做这个

    出一道题最容易的部分是「写出来」,最难的部分是它恰好是一道好题:有解、解唯一、没有增根、难度可控、和别的题不重复。

    坏题只有三种形态,而每一种都在真实试卷里反复出现:

    形态例子后果
    无解 要求「求两根」学生算到判别式为负,怀疑自己
    多解分式方程去分母后引入增根答案对不上,老师要逐个解释
    退化十道题里四道的常数项都是 0练不到东西

    现有题库工具几乎都停在「存题 + 抽题」:题是人写的,系统只负责索引。真正的难点不在抽,在造——而且不在造得出来,在造出来的题是良定的。

    与此同时,MoonBit 生态里已经有人把零件做齐了:符号计算(maxima、symbit)、排版(moonbitlang/typst、pagelayout)、约束求解(moonplan、cassowary)。唯独没有人做「命题」这件事本身。 kiln 不去重造这些零件,它做的是把它们装配起来解决一个还没被建模的问题。


    #四个核心设计

    #1. 逆向命题:从答案反解参数

    常见做法是随机采样参数、算出答案、不合格就丢掉——命中率常常只有个位数,难度完全不可控。

    kiln 把方向反过来:先声明答案应该长什么样,再由关系式解出参数。

    answer { r1: int in [-9, 9] r2: int in [-9, 9] } param { a: 1 b: -(a * (r1 + r2)) # 韦达定理,由根反解 c: a * r1 * r2 } require { r1 < r2 }

    命中率 100%,难度从「事后测量」变成「事前声明」。

    #2. 适定性判定:把「题目没毛病」变成可判定的命题

    Hadamard 的适定性三条件在这里落成三条可执行检查:

    • 存在:判别式非负,确实有实根
    • 唯一:解集恰好等于声明的答案集,不多不少(含增根检测)
    • 稳定:任一系数 ±1 扰动后仍是两个相异实根

    前两条是硬门槛,不通过直接丢弃;第三条是软信号——判为 fragile 的题仍然正确,只是经不起重印或笔误,组卷时会排在后面。

    判不了的部分明确返回 Undecidable,绝不猜。

    #3. 答案凭证:生成即证明,且两条路径不共谋

    这是整个设计里最要紧的一条:生成器与验证器不共享推导路径。

    生成:选根 (r1, r2) → 韦达 → 系数 (a, b, c) 验证:取系数 (a, b, c) → 判别式 → 开方 → 求根公式 → 比对解集

    同一个符号错误不会在两条路上同时发生并互相抵消。于是 moon test 等价于全题库体检,不是抽查,是每一道。

    每道题都带一份凭证:系数、判别式、精确平方根、求根公式给出的根、扰动检验结果。第三方不需要信任生成器,照着凭证重算一遍即可。

    #4. 结构指纹:跨卷不撞题

    多项式按「同解」做规范化:清分母 → 约去公因子 → 首项取正。

    于是 2x²-10x+12=0、x²-5x+6=0、-x²+5x-6=0 得到同一个指纹 1,-5,6——它们本来就是同一道题。这个映射对一元多项式是 sound 且 complete 的:指纹相同 ⇔ 同解。

    再往上,组卷时用结构标签做多样性约束(zero-root、no-linear-term 硬限 1 题,both-negative、unit-root 软限 3 题),避免「十道题四道常数项为零」。


    #快速开始

    git clone https://github.com/DWS-ai-nb/kiln cd kiln moon update

    # 构建(Windows 上没有 MSVC 时用 js 后端) moon build --target js # 体检:生成全题池并逐题独立复核 moon run cmd/cli --target js -- check --schema examples/quadratic.kiln # 出卷:10 题,固定种子 moon run cmd/cli --target js -- gen --schema examples/quadratic.kiln \ --count 10 --seed 7 --out out # 按难度配额组卷 moon run cmd/cli --target js -- gen --schema examples/quadratic.kiln \ --levels 1:2,2:3,3:3,4:2 --seed 2026 --out out # 复核一份卷子:不读 schema,不重跑生成器,只凭证重算 moon run cmd/cli --target js -- verify --file out/paper.json # 平行卷:给 A 卷造一份结构等价的 B 卷(难度逐题对齐、结构标签一致、题目不重) moon run cmd/cli --target js -- parallel --schema examples/quadratic.kiln --file out/paper.json --seed 11 --out out-b # 跨周复用:这周发的题,下周不再出现 moon run cmd/cli --target js -- gen --schema examples/quadratic.kiln \ --count 8 --seed 22 --out out --exclude out/ledger.txt --record out/ledger.txt

    账本是一行一个结构指纹的纯文本文件,可以进版本库、可以 diff,也可以手工删掉某几行把某道题放回题池。

    产物三份,都是自包含的 HTML(无 CDN、无外链字体、无 JS 依赖,直接打印):

    • out/worksheet.html — 学生卷,题序按难度递增
    • out/answers.html — 答案页,含难度依据与复核凭证
    • out/worksheet.md — 便于粘贴的纯文本版
    • out/paper.json — 卷子文件,每道题带可复核凭证

    仓库里带三个示例 schema:

    文件族说明
    examples/quadratic.kilnquadratic整数根一元二次方程,171 道唯一题
    examples/rational.kilnrational分式方程,312 道唯一题
    examples/rational_with_extraneous.kilnrational反面教材:故意不写 r != k,让验证器拦下 26 道增根题

    出卷失败不会返回一张残卷,而是指名道姓地告诉你缺口:

    组卷失败:L5 需要 30 题,可用的只有 12 题(缺口 18,可能受结构多样性上限限制)


    #任何人都能复核

    这是整个项目最想被检验的一点:卷子不是一堆文本,是一份可复核的数据。

    paper.json 里每道题带一段 payload——二次方程是 [a, b, c],分式方程是 [n₀, n₁, d₀, d₁, q]。拿它和答案,任何人都能把答案从头算一遍:

    $ python3 tools/verify_reference.py out/paper.json schema=quadratic_int_roots items=4 passed=4 failed=0

    这个 Python 实现 112 行有效代码,用 fractions.Fraction 全程精确,跟 MoonBit 那份不共享任何代码。CI 每次生成后都会跑它——两边结论不一致,构建就失败。

    改了答案会怎样:

    $ python3 tools/verify_reference.py tampered.json 1: answer-mismatch: formula gives [Fraction(-4, 1), Fraction(0, 1)], declared [Fraction(99, 1)] schema=quadratic_int_roots items=4 passed=3 failed=1 # 退出码 1

    格式规范在 docs/CERTIFICATE.md,包含每种族的 payload 定义、逐步算法,以及它不保证什么(题干文字不参与校验——凭证管数学,不管文风)。


    #schema 语言

    schema 是声明式的,不做计算,只描述「题族长什么样」:

    子句作用
    schema "name"唯一标识
    family quadratic选择验证策略
    title "..."卷面标题
    answer { ... }答案变量,必须带整数域 [lo, hi]
    param { ... }参数:带域则枚举,带表达式则推导(可引用前面已绑定的变量)
    require { ... }并列条件,多条之间是「且」;表达式引用未定义变量会在加载时报错
    ask "..."题面模板,{poly} 由验证族渲染

    加载期做静态检查:重名、未定义变量、空域、缺失 ask,都会在解析后立刻报错并给出行号,而不是等到运行时炸掉。


    #架构

    文件职责关键点
    rat.mbt精确有理数归一化不变量(den>0、互素、零为 0/1);精确开方
    poly.mbt一元多项式正规形;canonical_key 做同解规范化
    lexer.mbt词法不抛异常,坏字符变 TBad 由语法层定位
    ast.mbt表达式与求值除以零返回 None 而非崩溃,语义上就是「此处无定义」
    parse.mbt递归下降语法错误一律带行号;解析与校验分离
    engine.mbt枚举与题池深度优先枚举推导链,叶子处做条件检查与去重
    verify.mbt独立验证判别式 → 开方 → 求根公式,与生成路径完全分离
    family_quadratic.mbt二次族逆向构造、题面渲染、可解释难度、结构标签
    family_rational.mbt分式族交叉相乘验证、增根判定、分母零点检查
    canon(在 poly.mbt)结构指纹同解规范化
    paper.mbt组卷难度配额 + 结构多样性 + 已选集合 + 不可行解释
    render.mbt输出HTML / Markdown,全部转义
    paper_io.mbt卷子文件kiln.paper/1 的读写与独立校验
    tools/verify_reference.py参考校验器112 行 Python,与实现零共享
    rng.mbt确定性随机splitmix64,20 行,不依赖平台熵
    cmd/cli命令行四个动词,无配置文件;账本读写在 CLI 层
    golden_wbtest.mbt跨后端黄金测试把整卷输出钉死,两个后端同时校验
    properties_test.mbt属性测试验证器一致性、指纹缩放不变、指纹幂等

    数据流:

    schema.kiln ──parse──> Schema ──enumerate──> 候选参数组 │ family build(逆向) │ verify(正向,独立) │ ┌───────────────────┴──────────────┐ WellPosed NotWellPosed │ │ Item + 凭证 + 指纹 计入 rejects │ paper.assemble(配额 / 多样性 / 去重) │ render → HTML / Markdown


    #设计决定记录

    被否掉的方案比选中的方案更能说明一个项目的位置。docs/DECISIONS.md 记了六条真正影响结构的判断:为什么只做一元多项式、为什么生成器与验证器必须走两条路、为什么指纹按同解而不是按系数、为什么「稳定」不是硬门槛、为什么账本是纯文本、为什么组卷是贪心而不是搜索。每条都写了放弃的替代方案和代价。


    #工程实践

    • CI 在 wasm-gc 与 js 两个后端分别跑测试,同时做 moon check --target all --deny-warn 和 moon fmt 的 diff 检查。警告即错误,格式必须是规范形式。
    • 属性测试用标准库 @quickcheck,失败时给的是收缩后的最小反例,不是一坨随机输入。
    • 黄金文本把整卷输出钉死。任何引入浮点、哈希迭代序或本地化差异的改动,会在两个后端之一先露出来。
    • native 后端单独一个 job 且 continue-on-error:它需要 C 工具链,不该因为环境缺失就把主流程标红,但也不假装它一定通过。


    #设计取舍

    不重造轮子,但也不当胶水。 生态里已有的 CAS、排版引擎、通用约束求解器一律不碰。kiln 的独有部分是「逆向构造 + 适定性判定 + 凭证 + 指纹」这条链,它不属于上面任何一类。

    零浮点。 全程 Int64 有理数。原因有二:教学题目的答案本就该精确;浮点会毁掉跨后端逐字节一致性这个可验证承诺。

    自研 PRNG 而非 @random。 只用 20 行 splitmix64,因为可复现性是契约:同一 seed 必须在每个后端产出同一张卷子,不能依赖平台熵的回落行为。

    难度是可解释的,不是黑箱。 一个小的加性模型,每个因子都有名字(「根绝对值较大」「两根异号」「方程缺项(偏易)」)。老师可以不同意模型,然后去改 schema——而不是面对一个神秘数字。

    边界清楚到能写进 README。 只做可判定子集;判不了就返回 Undecidable。


    #测试

    69 个测试,moon test 全绿;moon test --target wasm-gc 与 --target js 各跑一遍,同一个黄金文本必须同时通过。

    • 精确算术:符号归一、约分、比较、精确开方(含非完全平方必须返回 None)
    • 多项式:展开、求值、同解规范化(缩放合并、不同方程不误合并、有理系数清分母)
    • 词法/语法:双字符运算符、注释、行号定位、未定义变量、空域
    • 验证器:正例、错答案、负判别式、重根、无理根——五种判定各自有独立用例
    • 引擎:题池规模与去重计数、全量复检、可复现性
    • 组卷:配额、种子可复现、不可行解释、排除集、同卷不得重题(回归用例)、退化结构不得霸屏(跨 12 个种子扫描)
    • 属性测试(@quickcheck,带收缩):任取两个相异整数根构造的方程必被验证器接受;任意非零缩放下结构指纹不变;指纹可反向重建出同一方程
    • 卷子文件:JSON 往返后仍全部通过;篡改系数或答案必被拒;缺字段、版本不匹配、非 JSON 都给可读原因
    • 跨后端黄金文本:固定 seed 的整卷输出逐字符写死,两个后端都必须一字不差——这是 README 里「逐字节一致」那句话的证明,不是修辞

    几何测试(跨种子扫描、全池复检)比单点断言更能兜住回归——上面那条「同卷重题」的 bug 就是被跨种子扫描抓出来的。


    #边界

    明确不做:

    • 不做完整 CAS(maxima / symbit 已经在生态里)
    • 不做排版引擎(typst / pagelayout 已经在生态里)
    • 不做通用约束求解器(moonplan / cassowary 已经在生态里)
    • 不做几何证明、不做自然语言应用题
    • 不做在线刷题平台、不做账号体系

    目前已实现两个验证族:quadratic(整数根一元二次方程)与 rational(分式方程,含增根判定)。

    加第三个族只需要:一个 build_*、一个 verify_*、一组结构标签,以及一条 schema。引擎的枚举、去重、组卷、渲染全部复用——不需要改 engine.mbt 以外的任何调度代码。

    分式族是「适定性」最有说服力的证据。把它那条 require r != k 删掉,26 个候选会去分母解出根恰好等于分母零点——方程其实无解,属于典型增根。验证器全部拦下,reason 写作 extraneous-root:

    $ moon run cmd/cli --target js -- check --schema examples/rational_with_extraneous.kiln candidates 338 (rejected 26, duplicates 0) rejects 1 distinct reason(s) - extraneous-root


    #路线图


    #许可

    Apache-2.0。参考的算法与思路(Kiwi 的增量约束、QuickCheck 的收缩、Hadamard 的适定性)均在 NOTICE 中注明;实现独立完成,无第三方运行时依赖(仅 moonbitlang/x 提供文件读写)。

    SchemaError

    pub suberror SchemaError {
    SchemaError(String)
    }

    Recursive-descent parser for .kiln schema files.

    Errors are reported as line N: ... strings and always point at the token that broke the grammar, never at an internal function.

    Blueprint

    pub(all) struct Blueprint {
    total : Int
    per_level : Array[(Int, Int)]
    seed : Int
    exclude : Array[String]
    max_per_tag : Int
    }

    Paper assembly.

    A pool of individually verified items is not a worksheet. Assembly has to satisfy a shape (how many items at each difficulty), keep the sheet diverse enough to be worth doing, avoid items already handed out, and — when it cannot — say exactly which part of the shape is impossible instead of silently returning a short paper.

    Blueprint::capping

    fn Blueprint::capping(self : Blueprint, max_per_tag : Int) -> Blueprint

    Blueprint::excluding

    fn Blueprint::excluding(self : Blueprint, fingerprints : Array[String]) -> Blueprint

    Blueprint::new

    fn Blueprint::new(total : Int, seed : Int) -> Blueprint

    Blueprint::with_levels

    fn Blueprint::with_levels(per_level : Array[(Int, Int)], seed : Int) -> Blueprint

    Certificate

    pub(all) struct Certificate {
    fields : Array[(String, String)]
    payload : Array[Rat]
    robust : Bool
    }

    What a third party needs to re-check an item without trusting the generator.

    fields is human-readable evidence; payload is the raw data the verifier consumes, in family-defined order. Keeping the payload numeric (and exact) is what makes independent re-verification possible from the outside.

    Certificate::field

    fn Certificate::field(self : Certificate, key : String) -> String?

    Certificate::to_lines

    fn Certificate::to_lines(self : Certificate) -> Array[String]

    CmpOp

    pub enum CmpOp {
    CLt
    CLe
    CGt
    CGe
    CEq
    CNe
    }

    CmpOp::holds

    fn CmpOp::holds(self : CmpOp, a : Rat, b : Rat) -> Bool

    CmpOp::to_string

    fn CmpOp::to_string(self : CmpOp) -> String

    Expr

    pub enum Expr {
    ELit(Rat)
    EVar(String)
    ENeg(Expr)
    EAdd(Expr, Expr)
    ESub(Expr, Expr)
    EMul(Expr, Expr)
    EDiv(Expr, Expr)
    EPow(Expr, Int)
    }

    Abstract syntax for the .kiln schema language.

    The language is intentionally tiny: it can enumerate answer shapes, derive parameters from them, and state side conditions. Anything that needs real mathematics (solving, verifying) lives in the engine, not in the schema.

    Expr::eval

    fn Expr::eval(self : Expr, env : Map[String, Rat]) -> Rat?

    Expr::to_string

    fn Expr::to_string(self : Expr) -> String

    Expr::vars

    fn Expr::vars(self : Expr) -> Array[String]

    Family

    pub enum Family {
    FQuadratic
    FRational
    }

    Family::from_string

    fn Family::from_string(s : String) -> Family?

    Family::to_string

    fn Family::to_string(self : Family) -> String

    Item

    pub(all) struct Item {
    schema_name : String
    question : String
    answers : Array[Rat]
    level : Int
    reasons : Array[String]
    fingerprint : String
    certificate : Certificate
    robust : Bool
    signature : String
    family : Family
    tags : Array[String]
    }

    The generated artifact: one problem, its answers, why it is rated the way it is, and the certificate that lets anyone re-check it.

    Item::answer_text

    fn Item::answer_text(self : Item) -> String

    Item::level_label

    fn Item::level_label(self : Item) -> String

    Item::reasons_text

    fn Item::reasons_text(self : Item) -> String

    ItemFile

    pub(all) struct ItemFile {
    question : String
    answers : Array[Rat]
    level : Int
    fingerprint : String
    family : Family
    payload : Array[Rat]
    tags : Array[String]
    }

    Paper

    pub(all) struct Paper {
    schema_name : String
    items : Array[Item]
    seed : Int
    request : String
    }

    Paper::size

    fn Paper::size(self : Paper) -> Int

    Paper::tag_counts

    fn Paper::tag_counts(self : Paper) -> Map[String, Int]

    PaperFile

    pub(all) struct PaperFile {
    format : String
    schema_name : String
    title : String
    seed : Int
    items : Array[ItemFile]
    }

    Poly

    pub struct Poly {
    coeffs : Array[Rat]
    }

    Univariate polynomials with exact rational coefficients.

    coeffs[i] is the coefficient of x^i. Trailing zero coefficients are stripped by every constructor, so two polynomials are structurally equal iff their coefficient arrays are equal.

    Scope note: the engine is deliberately univariate. Multivariate symbolic algebra already exists in the MoonBit ecosystem (maxima, symbit); this project is about constructing well-posed instances, not about being a CAS.

    Poly::add

    fn Poly::add(self : Poly, other : Poly) -> Poly

    Poly::canonical_key

    fn Poly::canonical_key(self : Poly) -> String

    Canonical key of the equation P(x) = 0, invariant under scaling.

    Scaling is a genuine equivalence for equations: 2x²-10x+12=0 and x²-5x+6=0 have the same solution set. Coercing coefficients to primitive integers with a positive leading coefficient therefore merges exactly the polynomials that describe the same equation — and nothing else.

    The map is sound (equal keys ⇒ same equation) and, for univariate polynomials, also complete (same equation over ℚ ⇒ equal keys).

    Poly::coeff

    fn Poly::coeff(self : Poly, i : Int) -> Rat

    Poly::constant

    fn Poly::constant(c : Rat) -> Poly

    Poly::degree

    fn Poly::degree(self : Poly) -> Int

    Poly::eval

    fn Poly::eval(self : Poly, at : Rat) -> Rat

    Evaluate the polynomial at an exact point (Horner, exact arithmetic).

    Poly::is_zero

    fn Poly::is_zero(self : Poly) -> Bool

    Poly::leading

    fn Poly::leading(self : Poly) -> Rat

    Poly::mul

    fn Poly::mul(self : Poly, other : Poly) -> Poly

    Poly::neg

    fn Poly::neg(self : Poly) -> Poly

    Poly::new

    fn Poly::new(coeffs : Array[Rat]) -> Poly

    Poly::pow

    fn Poly::pow(self : Poly, exp : Int) -> Poly

    Poly::scale

    fn Poly::scale(self : Poly, k : Rat) -> Poly

    Poly::sub

    fn Poly::sub(self : Poly, other : Poly) -> Poly

    Poly::to_string

    fn Poly::to_string(self : Poly, v : String) -> String

    Human-readable form such as 2x² - 5x + 6.

    Poly::x

    fn Poly::x() -> Poly

    The polynomial x.

    Poly::zero

    fn Poly::zero() -> Poly

    Pool

    pub(all) struct Pool {
    items : Array[Item]
    attempted : Int
    rejected : Int
    duplicates : Int
    rejects : Array[String]
    }

    A generation run. The counters are part of the product: "1083 candidates, 0 rejected, 41 duplicates" is a much better claim than "it works".

    Pool::count_at

    fn Pool::count_at(self : Pool, level : Int) -> Int

    Pool::level_histogram

    fn Pool::level_histogram(self : Pool) -> String

    Pool::size

    fn Pool::size(self : Pool) -> Int

    Rat

    pub struct Rat {
    num : Int64
    den : Int64
    }

    Exact rational arithmetic.

    Invariants maintained by every constructor in this file:
    • den > 0
    • gcd(|num|, den) == 1
    • zero is always represented as 0/1

    The engine never uses Double. Every quantity that ends up on a worksheet is an exact rational, which is what makes byte-identical output across the wasm-gc / js / native backends possible.

    Rat::abs

    fn Rat::abs(self : Rat) -> Rat

    Rat::add

    fn Rat::add(self : Rat, other : Rat) -> Rat

    Rat::compare

    fn Rat::compare(self : Rat, other : Rat) -> Int

    Rat::den

    fn Rat::den(self : Rat) -> Int64

    Rat::div

    fn Rat::div(self : Rat, other : Rat) -> Rat

    Rat::equal

    fn Rat::equal(self : Rat, other : Rat) -> Bool

    Rat::integer_sqrt

    fn Rat::integer_sqrt(self : Rat) -> Int64?

    Integer square root: returns Some(k) when self is a perfect square of a non-negative integer. The engine uses this to decide whether a discriminant of a quadratic yields rational roots.

    Rat::is_integer

    fn Rat::is_integer(self : Rat) -> Bool

    Rat::is_negative

    fn Rat::is_negative(self : Rat) -> Bool

    Rat::is_zero

    fn Rat::is_zero(self : Rat) -> Bool

    Rat::mul

    fn Rat::mul(self : Rat, other : Rat) -> Rat

    Rat::neg

    fn Rat::neg(self : Rat) -> Rat

    Rat::num

    fn Rat::num(self : Rat) -> Int64

    Rat::pow

    fn Rat::pow(self : Rat, exp : Int) -> Rat

    Rat::sign

    fn Rat::sign(self : Rat) -> Int

    Sign of the rational: -1, 0, or 1.

    Rat::sqrt_exact

    fn Rat::sqrt_exact(self : Rat) -> Rat?

    Exact square root of a non-negative rational, when it exists.

    Some(5) for 25, Some(1/2) for 1/4, None for 2 or 1/3.

    Rat::sub

    fn Rat::sub(self : Rat, other : Rat) -> Rat

    Rat::to_string

    fn Rat::to_string(self : Rat) -> String

    Render as p/q, or as a bare integer when the denominator is one.

    Require

    pub struct Require {
    left : Expr
    op : CmpOp
    right : Expr
    }

    Require::check

    fn Require::check(self : Require, env : Map[String, Rat]) -> Bool?

    None means the clause is undefined at this point (a subexpression divided by zero), which the generator treats as "outside the domain", not as an error.

    Rng

    pub struct Rng {
    state : UInt64
    }

    Deterministic pseudo-random source (splitmix64).

    Why not @random? Two reasons that both matter for this project:
    • reproducibility is a contract — the same seed must produce the same paper on wasm-gc, js and native, byte for byte;
    • the generator must not depend on platform entropy fallbacks. splitmix64 is ten lines, has no state beyond one word, and produces the same stream everywhere.

    Rng::below

    fn Rng::below(self : Rng, n : Int) -> Int

    Uniform-ish integer in [0, n). n <= 0 yields 0.

    Rng::new

    fn Rng::new(seed : Int) -> Rng

    Rng::next_u64

    fn Rng::next_u64(self : Rng) -> UInt64

    Schema

    pub(all) struct Schema {
    name : String
    family : Family
    title : String
    answers : Array[VarDecl]
    params : Array[VarDecl]
    requires : Array[Require]
    ask : String
    }

    Slot

    pub(all) struct Slot {
    level : Int
    tags : Array[String]
    fingerprint : String
    }

    The structural profile of one slot: difficulty plus tags. Both the in-memory Paper and the on-disk PaperFile reduce to this, so the same matcher works for either.

    Tok

    pub enum Tok {
    TIdent(String)
    TNum(Int64)
    TStr(String)
    TPunct(String)
    TBad(Char)
    TEof
    }

    Tokenizer for the .kiln schema language.

    The lexer never fails. Unrecognized characters become TBad tokens and the parser turns the first one it meets into a positioned error message, so a typo in a schema file always points at a line instead of a stack trace.

    Token

    pub struct Token {
    kind : Tok
    line : Int
    }

    VarDecl

    pub struct VarDecl {
    name : String
    domain : (Int, Int)?
    expr : Expr?
    }

    A declared variable is either enumerated over an integer interval or derived from previously bound variables.

    Verdict

    pub enum Verdict {
    WellPosed
    NotQuadratic
    NoRealRoots
    DoubleRoot
    IrrationalRoots
    ExtraneousRoot
    Identity
    NotLinear
    AnswerMismatch(String)
    }

    Independent verification of a quadratic item.

    This file deliberately shares no derivation path with the generator. The generator goes forward: pick roots, build coefficients by Vieta. The verifier goes backward: take coefficients, compute the discriminant, take square roots, compare solution sets. A single sign slip in either path shows up as a disagreement instead of silently cancelling out.

    Verdict::is_well_posed

    fn Verdict::is_well_posed(self : Verdict) -> Bool

    Verdict::to_string

    fn Verdict::to_string(self : Verdict) -> String

    VerifyReport

    pub(all) struct VerifyReport {
    total : Int
    passed : Int
    failures : Array[String]
    }

    VerifyReport::all_passed

    fn VerifyReport::all_passed(self : VerifyReport) -> Bool

    assemble

    fn assemble(pool : Pool, schema_name : String, bp : Blueprint) -> Result[Paper, String]

    Assemble a paper, or explain why the blueprint cannot be satisfied.

    The error message names the level and the exact shortfall. "生成失败" is not an acceptable answer for a teacher who asked for five level-3 problems.

    build_quadratic

    fn build_quadratic(schema : Schema, env : Map[String, Rat]) -> Result[Item, String]

    build_rational

    fn build_rational(schema : Schema, env : Map[String, Rat]) -> Result[Item, String]

    Build one rational item from a parameter environment.

    Declared parameters: p (numerator offset), k (denominator offset), q (right-hand side), and the answer variable (the intended root).

    classify_quadratic

    fn classify_quadratic(b : Rat, c : Rat, roots : Array[Rat]) -> (Int, Array[String])

    Explainable difficulty: a small additive model over named factors.

    The point is not psychometric precision — that needs response data we do not have. The point is that every level comes with the reasons that produced it, so a teacher can disagree with the model and tune the schema instead of treating the number as magic.

    classify_rational

    fn classify_rational(r : Rat, k : Rat, q : Rat) -> (Int, Array[String])

    escape_html

    fn escape_html(s : String) -> String

    Rendering.

    Output is plain HTML with no external assets: no CDN, no webfont, no math renderer. A worksheet that needs the network to look right is not a worksheet. Exponents use Unicode superscripts, which print correctly in every browser and survive copy-paste into chat.

    generate

    fn generate(schema : Schema, limit : Int) -> Pool

    Generate a pool of verified items.

    limit = 0 means "no limit". Items are produced in a deterministic order, and the first occurrence of a fingerprint wins, so the same schema always yields the same pool.

    join_lines

    fn join_lines(lines : Array[String]) -> String

    load_schema

    fn load_schema(src : String) -> Result[Schema, String]

    Load and validate a schema. Validation is separate from parsing so that a malformed program (undefined variable, no answers, duplicate name) is reported as clearly as a malformed file.

    paper_from_json_string

    fn paper_from_json_string(src : String) -> Result[PaperFile, String]

    paper_to_json

    fn paper_to_json(paper : Paper, title : String) -> Json

    paper_to_json_string

    fn paper_to_json_string(paper : Paper, title : String) -> String

    parallel_form

    fn parallel_form(pool : Pool, paper : Paper, seed : Int) -> Result[Paper, String]

    Build a parallel form from an assembled paper.

    parallel_paper_file

    fn parallel_paper_file(pool : Pool, pf : PaperFile, seed : Int) -> Result[Paper, String]

    Build a parallel form straight from a paper file, without going through the generator. This is the path the CLI takes: A.json in, B.json out.

    parse_int_opt

    fn parse_int_opt(s : String) -> Int?

    Parse a decimal integer, tolerating a leading + or -.

    parse_rat

    fn parse_rat(s : String) -> Rat?

    Parse p/q or a bare integer. Used when reading a paper file back, where every number must round-trip exactly — which is why the wire format uses strings instead of JSON numbers.

    quick_paper

    fn quick_paper(schema_src : String, count : Int, seed : Int) -> Result[Paper, String]

    kiln — well-posed problem generation with machine-checkable certificates.

    The library is arranged as a pipeline, and each stage is usable on its own:

    1. load_schema text → Schema (parse + static validation)
    2. generate Schema → Pool of independently verified Items
    3. assemble Pool + Blueprint → Paper
    4. render_* Paper → self-contained HTML / Markdown

    The convenience entry point for embedders is quick_paper, which runs the first three stages and reports every failure as a string instead of raising.

    quick_paper_with

    fn quick_paper_with(schema_src : String, per_level : Array[(Int, Int)], seed : Int) -> Result[Paper, String]

    Same as quick_paper, but with an explicit difficulty shape.

    rat

    fn rat(num : Int64, den : Int64) -> Rat

    Build a normalized rational. Aborts on a zero denominator: that is a programming error in the engine, never a user-facing condition. Callers that must detect a zero denominator check it before dividing.

    rat_int

    fn rat_int(n : Int64) -> Rat

    rat_one

    fn rat_one() -> Rat

    rat_zero

    fn rat_zero() -> Rat

    rebuild_quadratic

    fn rebuild_quadratic(cert : Certificate) -> Poly

    Rebuild the object under test from a certificate payload. For the quadratic family the payload is [a, b, c] in that order.

    render_answer_key

    fn render_answer_key(paper : Paper, title : String) -> String

    render_markdown

    fn render_markdown(paper : Paper, title : String) -> String

    render_worksheet

    fn render_worksheet(paper : Paper, title : String) -> String

    replace_all

    fn replace_all(s : String, from : String, to : String) -> String

    Small string helpers. Kept local on purpose: pulling in a dependency for replace_all would make the reproducibility story harder to audit.

    shuffle

    fn[T] shuffle(rng : Rng, xs : Array[T]) -> Unit

    In-place Fisher-Yates. The permutation depends only on the seed and the input length, never on hash iteration order.

    split_on

    fn split_on(s : String, sep : Char) -> Array[String]

    Split on a single separator character. Empty segments are preserved so that a,,b stays diagnosable instead of silently collapsing.

    tokenize

    fn tokenize(src : String) -> Array[Token]

    trim

    fn trim(s : String) -> String

    verify_item_file

    fn verify_item_file(item : ItemFile) -> Verdict

    Re-check one item from its payload alone. This is the function a third party implements in their own language; see docs/CERTIFICATE.md.

    verify_paper_file

    fn verify_paper_file(pf : PaperFile) -> VerifyReport

    Verify a whole paper. Failures carry the item index and the verdict, so a tampered file points at exactly which item stopped adding up.

    verify_quadratic

    fn verify_quadratic(p : Poly, declared : Array[Rat]) -> (Verdict, Certificate)

    Verify that p(x) = 0 has exactly the declared solution set, with two distinct rational roots.

    verify_rational

    fn verify_rational(num : Poly, den : Poly, rhs : Rat, declared : Array[Rat]) -> (Verdict, Certificate)