///|
/// 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.
pub(all) struct Blueprint {
total : Int32
///
/// `(level, count)` pairs. When empty, the assembler cycles through levels
/// 1..5 so that a plain "give me 10 problems" still yields a spread.
per_level : Array[(Int32, Int32)]
seed : Int32
///
/// Fingerprints that must not appear, e.g. everything already handed out in a
/// previous week.
exclude : Array[String]
///
/// Upper bound on how many items may share a structural tag. Degenerate tags
/// (`zero-root`, `no-linear-term`) are always capped at one.
max_per_tag : Int32
}
///|
pub(all) struct Paper {
schema_name : String
items : Array[Item]
seed : Int32
request : String
}
///|
pub fn Blueprint::new(total : Int32, seed : Int32) -> Blueprint {
{ total, per_level: [], seed, exclude: [], max_per_tag: 3, }
}
///|
pub fn Blueprint::with_levels(
per_level : Array[(Int32, Int32)],
seed : Int32,
) -> Blueprint {
{ total: sum_pairs(per_level), per_level, seed, exclude: [], max_per_tag: 3, }
}
///|
pub fn Blueprint::excluding(
self : Blueprint,
fingerprints : Array[String],
) -> Blueprint {
{ ..self, exclude: fingerprints, }
}
///|
pub fn Blueprint::capping(self : Blueprint, max_per_tag : Int32) -> Blueprint {
{ ..self, max_per_tag, }
}
///|
fn sum_pairs(ps : Array[(Int32, Int32)]) -> Int32 {
let mut s = 0
for p in ps {
let (_, n) = p
s = s + n
}
s
}
///|
/// Degenerate structures are capped hard: a sheet where half the equations are
/// missing their constant term teaches nothing.
fn tag_cap(tag : String, soft : Int32) -> Int32 {
if tag == "zero-root" || tag == "no-linear-term" {
1
} else if tag == "opposite-signs" {
// Two roots with opposite signs is the most valuable variation a sheet can
// have; capping it would push the hard levels out of reach.
999
} else {
soft
}
}
///|
fn tags_available(
tags : Array[String],
used : Map[String, Int32],
soft : Int32,
) -> Bool {
let mut ok = true
for t in tags {
match used.get(t) {
Some(n) => if n >= tag_cap(t, soft) { ok = false }
None => ()
}
}
ok
}
///|
fn mark_tags(tags : Array[String], used : Map[String, Int32]) -> Unit {
for t in tags {
match used.get(t) {
Some(n) => used[t] = n + 1
None => used[t] = 1
}
}
}
///|
/// Take `count` items of `level`, robust ones first, in a seed-shuffled order,
/// skipping anything that would push a structural tag past its cap.
/// Returns the shortfall so the caller can explain the failure precisely.
fn draw_level(
candidates : Array[Item],
level : Int32,
count : Int32,
rng : Rng,
used_tags : Map[String, Int32],
taken : Map[String, Bool],
soft_cap : Int32,
) -> (Array[Item], Int32) {
let robust : Array[Item] = []
let fragile : Array[Item] = []
for it in candidates {
if it.level == level && taken.get(it.fingerprint) is None {
if it.robust {
robust.push(it)
} else {
fragile.push(it)
}
}
}
shuffle(rng, robust)
shuffle(rng, fragile)
let picked : Array[Item] = []
let mut i = 0
while i < robust.length() && picked.length() < count {
if tags_available(robust[i].tags, used_tags, soft_cap) {
mark_tags(robust[i].tags, used_tags)
taken[robust[i].fingerprint] = true
picked.push(robust[i])
}
i = i + 1
}
let mut j = 0
while j < fragile.length() && picked.length() < count {
if tags_available(fragile[j].tags, used_tags, soft_cap) {
mark_tags(fragile[j].tags, used_tags)
taken[fragile[j].fingerprint] = true
picked.push(fragile[j])
}
j = j + 1
}
let shortfall = count - picked.length()
(picked, shortfall)
}
///|
/// 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.
pub fn assemble(
pool : Pool,
schema_name : String,
bp : Blueprint,
) -> Result[Paper, String] {
let candidates : Array[Item] = []
for it in pool.items {
if !contains_str(bp.exclude, it.fingerprint) {
candidates.push(it)
}
}
let rng = Rng::new(bp.seed)
let used_tags : Map[String, Int32] = Map([])
let taken : Map[String, Bool] = Map([])
let picked : Array[Item] = []
if bp.per_level.length() > 0 {
for entry in bp.per_level {
let (level, count) = entry
let (got, shortfall) = draw_level(
candidates,
level,
count,
rng,
used_tags,
taken,
bp.max_per_tag,
)
if shortfall > 0 {
return Err(
"L\{level} 需要 \{count} 题,可用的只有 \{count - shortfall} 题(缺口 \{shortfall},可能受结构多样性上限限制)",
)
}
for it in got {
picked.push(it)
}
}
} else {
let levels = [1, 2, 3, 4, 5]
let mut remaining = bp.total
let mut round = 0
while remaining > 0 {
let level = levels[round % 5]
let (got, _) = draw_level(
candidates,
level,
1,
rng,
used_tags,
taken,
bp.max_per_tag,
)
if got.length() == 1 {
picked.push(got[0])
remaining = remaining - 1
}
round = round + 1
if round > 400 {
let msg = "题池不足以凑满 "
let msg = msg + bp.total.to_string()
let msg = msg + " 题(已选出 "
let msg = msg + picked.length().to_string()
return Err(msg + " 题),可能受结构多样性上限限制")
}
}
}
let ordered = sort_by_level(picked)
// Describe what the paper actually turned out to be, not what was asked for:
// diversity caps can soften a quota, and the sheet should say so.
let parts : Array[String] = []
for level in 1..<=5 {
let mut n = 0
for it in ordered {
if it.level == level {
n = n + 1
}
}
if n > 0 {
parts.push("L\{level}×\{n}")
}
}
let request = join_with(parts, " ")
Ok({ schema_name, items: ordered, seed: bp.seed, request, })
}
///|
/// Stable sort by level so a printed sheet gets easier-to-harder ordering
/// instead of the interleaved order the quotas are drawn in.
fn sort_by_level(items : Array[Item]) -> Array[Item] {
let out = items.copy()
let mut i = 1
while i < out.length() {
let key = out[i]
let mut j = i - 1
while j >= 0 && out[j].level > key.level {
out[j + 1] = out[j]
j = j - 1
}
out[j + 1] = key
i = i + 1
}
out
}
///|
fn join_with(parts : Array[String], sep : String) -> String {
let sb = StringBuilder()
for i, p in parts {
if i > 0 {
sb.write_string(sep)
}
sb.write_string(p)
}
sb.to_string()
}
///|
pub fn Paper::size(self : Paper) -> Int32 {
self.items.length()
}
///|
pub fn Paper::tag_counts(self : Paper) -> Map[String, Int32] {
let counts : Map[String, Int32] = Map([])
for it in self.items {
mark_tags(it.tags, counts)
}
counts
}
///|
test "assembly honours per-level quotas" {
let schema = match load_schema(SAMPLE_SCHEMA) {
Ok(s) => s
Err(e) => abort("schema error: \{e}")
}
let pool = generate(schema, 0)
let bp = Blueprint::with_levels([(1, 2), (2, 2), (3, 2)], 7)
match assemble(pool, schema.name, bp) {
Ok(paper) => {
inspect(paper.size(), content="6")
inspect(paper.items[0].level, content="1")
inspect(paper.items[5].level, content="3")
}
Err(e) => abort("unexpected: \{e}")
}
}
///|
test "assembly is reproducible for a seed" {
let schema = match load_schema(SAMPLE_SCHEMA) {
Ok(s) => s
Err(_) => abort("schema error")
}
let pool = generate(schema, 0)
let bp = Blueprint::new(8, 2026)
let a = match assemble(pool, schema.name, bp) {
Ok(p) => p
Err(e) => abort(e)
}
let b = match assemble(pool, schema.name, bp) {
Ok(p) => p
Err(e) => abort(e)
}
let mut same = true
for i in 0.. s
Err(_) => abort("schema error")
}
let pool = generate(schema, 0)
let a = match assemble(pool, schema.name, Blueprint::new(6, 1)) {
Ok(p) => p
Err(e) => abort(e)
}
let b = match assemble(pool, schema.name, Blueprint::new(6, 2)) {
Ok(p) => p
Err(e) => abort(e)
}
let mut diff = 0
for i in 0.. 0, content="true")
}
///|
test "impossible quota explains the shortfall" {
let schema = match load_schema(SAMPLE_SCHEMA) {
Ok(s) => s
Err(_) => abort("schema error")
}
let pool = generate(schema, 0)
let bp = Blueprint::with_levels([(1, 3), (5, 400)], 3)
match assemble(pool, schema.name, bp) {
Ok(_) => abort("expected a shortfall")
Err(e) => {
inspect(e.contains("L5"), content="true")
inspect(e.contains("缺口"), content="true")
}
}
}
///|
test "excluded fingerprints never reappear" {
let schema = match load_schema(SAMPLE_SCHEMA) {
Ok(s) => s
Err(_) => abort("schema error")
}
let pool = generate(schema, 0)
let first = match assemble(pool, schema.name, Blueprint::new(5, 11)) {
Ok(p) => p
Err(e) => abort(e)
}
let used : Array[String] = []
for it in first.items {
used.push(it.fingerprint)
}
let second = match
assemble(pool, schema.name, Blueprint::new(5, 11).excluding(used)) {
Ok(p) => p
Err(e) => abort(e)
}
let mut overlap = 0
for it in second.items {
if contains_str(used, it.fingerprint) {
overlap = overlap + 1
}
}
inspect(overlap, content="0")
}
///|
/// Regression test for a real bug: drawing level by level re-shuffled the same
/// bucket and could hand out the same problem twice in one sheet.
test "no paper ever repeats a fingerprint" {
let schema = match load_schema(SAMPLE_SCHEMA) {
Ok(s) => s
Err(_) => abort("schema error")
}
let pool = generate(schema, 0)
let mut worst = 0
for seed in 1..<=10 {
let paper = match assemble(pool, schema.name, Blueprint::new(20, seed)) {
Ok(p) => p
Err(e) => abort(e)
}
let seen : Array[String] = []
let mut dup = 0
for it in paper.items {
if contains_str(seen, it.fingerprint) {
dup = dup + 1
} else {
seen.push(it.fingerprint)
}
}
if dup > worst {
worst = dup
}
}
inspect(worst, content="0")
}
///|
test "degenerate structures never dominate a paper" {
let schema = match load_schema(SAMPLE_SCHEMA) {
Ok(s) => s
Err(_) => abort("schema error")
}
let pool = generate(schema, 0)
// Sweep seeds: the cap must hold no matter how the shuffle falls.
let mut worst_zero = 0
let mut worst_missing = 0
for seed in 1..<=12 {
let paper = match assemble(pool, schema.name, Blueprint::new(10, seed)) {
Ok(p) => p
Err(e) => abort(e)
}
let counts = paper.tag_counts()
let zeros = match counts.get("zero-root") {
Some(n) => n
None => 0
}
let missing = match counts.get("no-linear-term") {
Some(n) => n
None => 0
}
if zeros > worst_zero {
worst_zero = zeros
}
if missing > worst_missing {
worst_missing = missing
}
}
inspect(worst_zero <= 1, content="true")
inspect(worst_missing <= 1, content="true")
}