///|
/// The paper file: a self-contained, verifiable record of a generated sheet.
///
/// This is what turns "the generator checked itself" into "anyone can check it".
/// The file carries, for every item, the exact payload the family's verifier
/// consumes — no schema, no source code, no trust required.
///
/// Two deliberate choices:
///
/// * **All numbers are strings.** JSON numbers are doubles; a fraction like
/// `1/3` would lose exactness, and a large integer would silently round.
/// * **The payload is opaque and family-defined.** The format does not try to
/// be a general math AST. Each family documents its own arity, which keeps
/// the verifier small and the format honest.
const PAPER_FORMAT : String = "kiln.paper/1"
///|
pub(all) struct ItemFile {
question : String
answers : Array[Rat]
level : Int32
fingerprint : String
family : Family
payload : Array[Rat]
tags : Array[String]
}
///|
pub(all) struct PaperFile {
format : String
schema_name : String
title : String
seed : Int32
items : Array[ItemFile]
}
///|
pub(all) struct VerifyReport {
total : Int32
passed : Int32
failures : Array[String]
}
///|
pub fn VerifyReport::all_passed(self : VerifyReport) -> Bool {
self.failures.length() == 0
}
///|
fn rats_to_json(values : Array[Rat]) -> Json {
let out : Array[Json] = []
for v in values {
out.push(Json::string(v.to_string()))
}
Json::array(out)
}
///|
pub fn paper_to_json(paper : Paper, title : String) -> Json {
let items : Array[Json] = []
for it in paper.items {
let obj : Map[String, Json] = Map([])
obj["question"] = Json::string(it.question)
obj["answers"] = rats_to_json(it.answers)
obj["level"] = Json::number(it.level.to_double())
obj["fingerprint"] = Json::string(it.fingerprint)
obj["family"] = Json::string(it.family.to_string())
obj["payload"] = rats_to_json(it.certificate.payload)
obj["tags"] = Json::array(it.tags.map(fn(t) { Json::string(t) }))
items.push(Json::object(obj))
}
let root : Map[String, Json] = Map([])
root["format"] = Json::string(PAPER_FORMAT)
root["schema"] = Json::string(paper.schema_name)
root["title"] = Json::string(title)
root["seed"] = Json::number(paper.seed.to_double())
root["items"] = Json::array(items)
Json::object(root)
}
///|
pub fn paper_to_json_string(paper : Paper, title : String) -> String {
paper_to_json(paper, title).stringify(indent=2)
}
///|
priv suberror PaperError {
PaperError(String)
}
///|
fn field_json(obj : Json, key : String) -> Json? {
if obj is Object(map) {
map.get(key)
} else {
None
}
}
///|
fn as_text(j : Json) -> String? {
if j is String(s) {
Some(s)
} else {
None
}
}
///|
fn as_number(j : Json) -> Double? {
if j is Number(d, ..) {
Some(d)
} else {
None
}
}
///|
fn as_items(j : Json) -> Array[Json]? {
if j is Array(xs) {
Some(xs)
} else {
None
}
}
///|
fn field_string(obj : Json, key : String) -> String raise PaperError {
match field_json(obj, key) {
Some(v) =>
match as_text(v) {
Some(s) => s
None => raise PaperError("字段 '\{key}' 应为字符串")
}
None => raise PaperError("缺少字段 '\{key}'")
}
}
///|
fn field_int(obj : Json, key : String) -> Int32 raise PaperError {
match field_json(obj, key) {
Some(v) =>
match as_number(v) {
Some(d) => d.to_int()
None => raise PaperError("字段 '\{key}' 应为数字")
}
None => raise PaperError("缺少字段 '\{key}'")
}
}
///|
fn field_array(obj : Json, key : String) -> Array[Json] raise PaperError {
match field_json(obj, key) {
Some(v) =>
match as_items(v) {
Some(a) => a
None => raise PaperError("字段 '\{key}' 应为数组")
}
None => raise PaperError("缺少字段 '\{key}'")
}
}
///|
fn field_strings(obj : Json, key : String) -> Array[String] raise PaperError {
let out : Array[String] = []
for entry in field_array(obj, key) {
match as_text(entry) {
Some(t) => out.push(t)
None => raise PaperError("字段 '\{key}' 的元素应为字符串")
}
}
out
}
///|
fn field_rats(obj : Json, key : String) -> Array[Rat] raise PaperError {
let out : Array[Rat] = []
for entry in field_array(obj, key) {
match as_text(entry) {
Some(s) =>
match parse_rat(s) {
Some(r) => out.push(r)
None =>
raise PaperError("字段 '\{key}' 含无法解析的数:\{s}")
}
None => raise PaperError("字段 '\{key}' 的元素应为字符串")
}
}
out
}
///|
pub fn paper_from_json_string(src : String) -> Result[PaperFile, String] {
try {
let root = @json.parse(src)
let format = field_string(root, "format")
if format != PAPER_FORMAT {
raise PaperError(
"格式版本不匹配:读到 \{format},本工具支持 \{PAPER_FORMAT}",
)
}
let items : Array[ItemFile] = []
for entry in field_array(root, "items") {
let family_text = field_string(entry, "family")
let family = match Family::from_string(family_text) {
Some(f) => f
None => raise PaperError("未知验证族:\{family_text}")
}
items.push({
question: field_string(entry, "question"),
answers: field_rats(entry, "answers"),
level: field_int(entry, "level"),
fingerprint: field_string(entry, "fingerprint"),
family,
payload: field_rats(entry, "payload"),
tags: field_strings(entry, "tags"),
})
}
Ok({
format,
schema_name: field_string(root, "schema"),
title: field_string(root, "title"),
seed: field_int(root, "seed"),
items,
})
} catch {
PaperError(msg) => Err(msg)
_ => Err("不是合法的 JSON")
}
}
///|
/// Re-check one item from its payload alone. This is the function a third party
/// implements in their own language; see `docs/CERTIFICATE.md`.
pub fn verify_item_file(item : ItemFile) -> Verdict {
match item.family {
FQuadratic => {
if item.payload.length() != 3 {
return AnswerMismatch(
"quadratic payload 应为 3 个数,实际 \{item.payload.length()}",
)
}
let p = Poly::new([item.payload[2], item.payload[1], item.payload[0]])
let (verdict, _) = verify_quadratic(p, item.answers)
verdict
}
FRational => {
if item.payload.length() != 5 {
return AnswerMismatch(
"rational payload 应为 5 个数,实际 \{item.payload.length()}",
)
}
let num = Poly::new([item.payload[0], item.payload[1]])
let den = Poly::new([item.payload[2], item.payload[3]])
let (verdict, _) = verify_rational(
num,
den,
item.payload[4],
item.answers,
)
verdict
}
}
}
///|
/// Verify a whole paper. Failures carry the item index and the verdict, so a
/// tampered file points at exactly which item stopped adding up.
pub fn verify_paper_file(pf : PaperFile) -> VerifyReport {
let failures : Array[String] = []
let mut passed = 0
for i, item in pf.items {
let verdict = verify_item_file(item)
if verdict.is_well_posed() {
passed = passed + 1
} else {
failures.push(
"第 \{i + 1} 题:\{verdict.to_string()}(\{item.question})",
)
}
}
{ total: pf.items.length(), passed, failures, }
}
///|
test "a paper round-trips through JSON and still verifies" {
let schema = match load_schema(SAMPLE_SCHEMA) {
Ok(s) => s
Err(e) => abort(e)
}
let pool = generate(schema, 0)
let paper = match assemble(pool, schema.name, Blueprint::new(6, 9)) {
Ok(p) => p
Err(e) => abort(e)
}
let text = paper_to_json_string(paper, "测试卷")
let parsed = match paper_from_json_string(text) {
Ok(pf) => pf
Err(e) => abort("解析失败:\{e}")
}
inspect(parsed.items.length(), content="6")
inspect(parsed.schema_name, content="quadratic_int_roots")
let report = verify_paper_file(parsed)
inspect(report.total, content="6")
inspect(report.passed, content="6")
inspect(report.all_passed(), content="true")
}
///|
test "a tampered payload is caught" {
let schema = match load_schema(SAMPLE_SCHEMA) {
Ok(s) => s
Err(e) => abort(e)
}
let pool = generate(schema, 0)
let paper = match assemble(pool, schema.name, Blueprint::new(3, 4)) {
Ok(p) => p
Err(e) => abort(e)
}
let parsed = match
paper_from_json_string(paper_to_json_string(paper, "测试卷")) {
Ok(pf) => pf
Err(e) => abort(e)
}
// Flip the constant term of the first item: the answers no longer match.
let payload = parsed.items[0].payload
payload[0] = payload[0].add(rat_one())
let report = verify_paper_file(parsed)
inspect(report.passed, content="2")
inspect(report.failures.length(), content="1")
inspect(report.failures[0].contains("第 1 题"), content="true")
}
///|
test "a tampered answer is caught" {
let schema = match load_schema(RATIONAL_SCHEMA) {
Ok(s) => s
Err(e) => abort(e)
}
let pool = generate(schema, 0)
let paper = match assemble(pool, schema.name, Blueprint::new(3, 4)) {
Ok(p) => p
Err(e) => abort(e)
}
let parsed = match
paper_from_json_string(paper_to_json_string(paper, "分式")) {
Ok(pf) => pf
Err(e) => abort(e)
}
parsed.items[0].answers[0] = parsed.items[0].answers[0].add(rat_one())
let report = verify_paper_file(parsed)
inspect(report.all_passed(), content="false")
}
///|
test "malformed files are rejected with a reason" {
match paper_from_json_string("{\"format\":\"kiln.paper/1\"}") {
Ok(_) => abort("expected an error")
Err(e) => inspect(e.contains("缺少字段"), content="true")
}
match paper_from_json_string("not json at all") {
Ok(_) => abort("expected an error")
Err(e) => inspect(e.contains("JSON"), content="true")
}
match paper_from_json_string("{\"format\":\"kiln.paper/99\"}") {
Ok(_) => abort("expected an error")
Err(e) => inspect(e.contains("格式版本"), content="true")
}
}