kiln

    Finite-domain arithmetic rule regression, replayable change witnesses and exact minimum-field counterexamples

    regression-testing
    counterexample
    constraint
    verification
    semantic-diff
    Download zip
    Author
    Version
    0.2.1
    License
    Apache-2.0
    Last updated
    5 days ago
    Downloads
    42

    #kiln · 规则变更回归与最小反例

    CI

    比较两版有限域算术规则,找出哪些输入从接受变成拒绝、哪些从拒绝变成接受,再求离指定基线改动字段最少的反例。 Pure MoonBit finite-domain rule regression and exact minimum-field change witnesses.

    例如订单原价 50、会员折扣 10、优惠券 0 的配置在两版都合法。新版增加“应付金额不能为负”后,kiln 找到只改变一个字段的区分输入:原价变为 0,旧版接受、新版因 nonnegative-charge 拒绝。见证可在两版原始模型上重新验证。

    项目面向需要在 CI 中审查跨字段业务约束的开发者。输入是显式有限整数域和算术/布尔 AST,不是任意源程序、JSON Schema 或设备配置的自动翻译。示例都是本项目编写的合成规则,不代表生产采用或实际客户数据。

    #版本与已有基础

    0.2.1 是 原版 kiln 0.1 被退回后的实质重建,沿用同一仓库并保留历史。旧版是两类方程的命题工具;新工作是独立的 model/、regression/、CLI、JS 桥及验证资产,不调用旧题族、旧有理数或旧组卷代码。旧版 69 项测试保留,不能计为本次新增测试。逐项回应见 REVIEW_RESPONSE。

    #安装与最小使用

    固定验证工具链:moonc 0.10.17+093cb8f9c 及其匹配 core。源码示例和测试只需 MoonBit;独立对照额外使用 Node.js 22+、Python 3.12,无第三方 Python 包。

    moon add DWS-ai-nb/kiln@0.2.1

    在调用方的 moon.pkg 中导入需要的包:

    import { "DWS-ai-nb/kiln/model", "DWS-ai-nb/kiln/regression", }

    业务规则纯库示例:moon run examples/regression_api,直接调用语义对比、最近反例与回放,输出 changes=1, minimum_fields=1, replay=true。另有 moon run examples/embedding 演示基础求解。两者都不依赖 CLI、Python 或网络求解服务。JS 导出接口在 api/,对实际 JS 参数校验类型、整数性和范围后才转换。

    #三个完整场景

    先从源码构建:

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

    场景输入与操作可复现的结果
    价格规则更新examples/regression/pricing/;新规则禁止负应付金额4 个旧接受/新拒绝输入;相对所给基线最近反例只改 1 个字段
    配置兼容回归examples/regression/deployment/;兼容表误删 JS/server 组合,保留存储与持久化条件输出受影响的完整配置及 runtime-host 违规,供部署测试回放
    选题配额修改examples/regression/quota/;基础题配额由 1 改为 2,互斥条件保留找到旧版唯一合法选集被拒绝;新配额无合法选集;可以继续用精确求解和冲突解释定位问题

    命令与退出码是 CI 接口的一部分。diff 检出变化、closest 找到变化时退出 1;这表示回归门禁检测到差异。变化不自动等于错误,预期修复也会改变接受集。

    # 退出 1:确有接受/拒绝变化;输出可回放输入和命名违规 moon run cmd/regression -- diff examples/regression/pricing/before.json examples/regression/pricing/after.json # 退出 1:找到距离基线最少改动字段的见证,minimal=true 才证明最优 moon run cmd/regression -- closest examples/regression/pricing/before.json examples/regression/pricing/after.json --baseline examples/regression/pricing/baseline.json # 退出 0:两种算术写法在声明域内等价 moon run cmd/regression -- diff examples/regression/equivalent/before.json examples/regression/equivalent/after.json # 退出 3:预算耗尽,无见证时 relation=unknown moon run cmd/regression -- diff examples/regression/pricing/before.json examples/regression/pricing/after.json --budget 0

    将 changes[i].assignment 单独保存为对象 JSON 后,可执行:

    moon run cmd/regression -- replay BEFORE.json AFTER.json --assignment case.json

    replay 只重算两版原始模型的接受与违规,不相信存档中的方向和统计。有效区分见证退出 0,非区分输入退出 1。输入错误退出 2。closest 即使已经找到见证,若 minimal=false,也不能称为最近反例。

    #模型与语义

    { "format": "kiln.model/1", "variables": [ {"name": "subtotal", "domain": [0, 50, 100]}, {"name": "coupon", "domain": [0, 20, 50]} ], "rules": [ {"id": "nonnegative", "assert": [">=", ["-", "subtotal", "coupon"], 0]} ] }

    • 数字是整数常量;字符串是变量引用;运算使用前缀数组。只接受无小数点/指数的整数拼写,避免输入被 Double 舍入或下溢。
    • 精确运算 + - * / % neg abs,每个中间结果必须在 ±10^12 内。/ 向零截断,% 随被除数符号。除零/越界为 undefined。
    • 规则支持 == != < <= > >= all any not implies distinct table count。indicator 把布尔规则转成 0/1,服务于精确 Hamming 目标;holds 把 undefined 总化为拒绝。
    • 普通 not(undefined) 仍 undefined。布尔组合允许确定性的真/假分支屏蔽无定义分支;详细规则见 MODEL_FORMAT。
    • 对比要求变量名集合一致,声明顺序可以不同;取两版原始域的并集作为比较范围,分别保留各自域的接受约束。目标函数、说明文字和规则 ID 不影响接受语义。

    flowchart LR A[两版域和约束] --> B[原域接受谓词与有限并集] B --> C[接受结果的对称差] C --> D[确定性完整搜索或预算中断] D --> E[回放区分输入] C --> F[Hamming 目标精确最小化]

    结果可以得出的结论
    relation=changed至少一个已回放的区分输入,差异确实存在
    relation=equivalent搜索穷尽且零区分输入,只针对报告中的有限域
    relation=unknown没有足够证据;不能按兼容或等价放行
    complete=false列表不是全部变化,常见原因是预算或见证数上限
    minimal=true已穷尽差异空间,Hamming 改动字段数为全局最小;并列解按确定性搜索顺序选取

    #通用内核与旧问题的解决

    model/ 提供无题族分派的库 API:solve、classify、check_assignment、minimize、explain、negate_rule。求解采用 MRV、候选前向检查和确定性回溯;最小化是有预算的完整枚举。explain 通过逐条删除检查得到相对原域不可再删的冲突子集,不宣称最小基数。

    # 配额与互斥精确建模:唯一选择 B/C/E,成本 12 moon run cmd/model -- solve examples/models/selection.json moon run cmd/model -- minimize examples/models/selection.json # 强制选择 A 后无解:返回四条相关冲突规则 moon run cmd/model -- explain examples/models/selection_unsat.json # 直接对命名不变量求反例;当前 CLI 输出中携带取反后的模型 moon run cmd/model -- counterexample examples/models/pricing.json --rule nonnegative-charge

    新增非二次题族不需要修改引擎:二元方程组、三次多项式、拉丁方都是数据模型示例。它们只针对显式有限整数域,不声称对实数域作完备代数求解。旧版的启发式“难度分数”仅留在历史 API,新功能输出实际工作计数,不把搜索成本称为学生认知难度。

    #验证与工程边界

    moon check --target all --deny-warn moon test --target wasm-gc --deny-warn moon test --target wasm --deny-warn moon test --target js --deny-warn moon fmt --check moon info moon build --target js moon build api --target js python tools/differential.py --cases 1600 python tools/regression_differential.py --cases 800 python tools/check_cli.py

    • 110 项 MoonBit 测试(原版 69、新增 41),覆盖语义、错误输入、预算、唯一性、精确最小化、双向差异、原域与并集、无定义算术、回放及 100 字段目标。
    • 独立 Python 解释器使用任意精度整数后施加范围契约,以固定顺序 DFS、仅在规则相关变量全绑定后求值;不复用 MoonBit 部分求值或 MRV。1700 个完整解集、897 对完整差异集,以及最近见证和预算测试可复现;其中含 93 个深层否定/总化组合的针对性模型,随机规则深度也提高到 4 层。
    • 102 个实际 JS ABI 边界检查、16 个独立参考输入边界检查、28 次 CLI 进程检查进入 CI;解释冲突的预算未知通过 typed explain_result 区分,CLI 退出 3。
    • CI 固定编译器与匹配 core,对 wasm-gc/wasm/js/native 强制检查、构建和测试,额外检查 CLI 真实退出码、公共接口漂移、原版跨语言回归。Windows 本机缺 MSVC,native 运行结果以 Linux CI 为准。
    • EVIDENCE 给出数据入口和实测范围;工作预算是候选探测次数,不是秒数、内存或指令总量上限。

    本版适合有界、小规模规则回归,搜索最坏情况指数级;无区间传播、SAT/SMT、BDD、单纯形、并行搜索或生产部署自动化。每模型最多 128 变量、256 命名规则、每域 2048 值、域值合计 16384;AST 总节点/表项 8192、深度 48,比较后的组合模型也要满足限制。默认预算 100000、最多 1000 见证;输入超限返回错误。

    #生态位置与来源

    现有 moonbit_constraint/MoonPlan 已提供通用有限域求解;CaseWeave 已提供组合测试;EvoWitness 已提供结构契约反例;MoonPolicyProof 已提供网络五元组差异。kiln 不把这些能力宣称为生态空白。这里交付的是跨字段算术约束版本的接受集差异、原域扩缩和相对配置基线的最少字段反例,不是它们的完整替代品;ECOSYSTEM 如实说明重叠、差异和检索盲区。

    源码与合成测试原创,AI 辅助设计、编码、测试和文档,参赛者须理解、审核和维护。没有移植上述项目源码。算法使用常见回溯、MRV、对称差、Hamming 距离和删除式冲突检查,不声称新算法。Apache-2.0;来源与依赖见 THIRD_PARTY 和根 LICENSE。

    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
    line : Int
    }

    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
    line : Int
    }

    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?
    line : Int
    }

    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)