feat: improve worksheet

This commit is contained in:
2026-08-10 01:52:15 -04:00
parent f475c630e0
commit 327ac371e4
17 changed files with 1469 additions and 49 deletions
+1
View File
@@ -1,4 +1,5 @@
preview
scratch
/dist/
/THIRD-PARTY-LICENSES.txt
+4 -4
View File
@@ -444,7 +444,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
for iteration in 0..opts.max_iterations {
iterations = iteration + 1;
// ---- E step: expected counts at each quadrature point ----
// --- E step: expected counts at each quadrature point ----
// Counts are accumulated per item rather than globally, so an item
// administered to only some examinees is not charged for the others.
let mut n_kj = vec![vec![0.0f64; j_count]; n_quad];
@@ -473,7 +473,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
}
}
// ---- M step: one two-parameter Newton solve per item ----
// --- M step: one two-parameter Newton solve per item ----
let mut delta = 0.0f64;
for j in 0..j_count {
let counts: Vec<(f64, f64)> = (0..n_quad).map(|k| (n_kj[k][j], r_k[k][j])).collect();
@@ -529,7 +529,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
));
}
// ---- Standard errors and per-item notes ----
// --- Standard errors and per-item notes ----
let (grid_final, weight_final) = (grid.clone(), base_weight.clone());
let mut p_grid = vec![vec![0.0f64; j_count]; n_quad];
for k in 0..n_quad {
@@ -601,7 +601,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
});
}
// ---- Abilities, expected a posteriori ----
// --- Abilities, expected a posteriori ----
let mut abilities = Vec::with_capacity(n);
let mut log_likelihood = 0.0f64;
for i in 0..n {
+78 -4
View File
@@ -571,6 +571,77 @@ fn asset_schema() -> Value {
})
}
/// The worked solution, and for an open-response item how it is graded.
fn solution_schema() -> Value {
json!({
"type": "object",
"additionalProperties": false,
"description": "The answer, the reasoning, and the rubric. Rendered in the solutions \
document and the answer key, never in a question paper.",
"properties": {
"model_answer": {
"type": "string",
"description": "For an open-response item, the response a full-credit student \
writes; for a choice item, an optional one-line statement of the key."
},
"explanation": {
"type": "string",
"description": "The worked reasoning a student learns from. The body of the \
solutions entry."
},
"rubric": { "type": "array", "items": rubric_criterion_schema() },
"accepted": {
"type": "array",
"items": { "type": "string" },
"description": "Responses a short constructed answer is accepted as."
},
"review": {
"type": "array",
"items": citation_schema(),
"description": "Where to look again after missing this item."
}
}
})
}
/// One rubric line for an open-response item.
fn rubric_criterion_schema() -> Value {
json!({
"type": "object",
"required": ["description"],
"additionalProperties": false,
"properties": {
"description": text("What earns the points on this line."),
"points": { "type": "number", "minimum": 0.0 }
}
})
}
/// A citation into the reference registry, written as an object or a bare string.
fn citation_schema() -> Value {
json!({
"oneOf": [
{ "type": "string", "description": "A citation, unparsed." },
citation_mapping_schema()
]
})
}
/// The object form of a citation.
fn citation_mapping_schema() -> Value {
json!({
"type": "object",
"additionalProperties": false,
"properties": {
"ref": text("Citation key into `references`."),
"locator": { "type": "string", "description": "Where inside the work: §6.1, pp. 4-9." },
"path": { "type": "string", "description": "Joined to the reference base_url." },
"url": { "type": "string" },
"text": { "type": "string" }
}
})
}
/// The schema for authored design intent.
fn design_schema() -> Value {
json!({
@@ -738,9 +809,10 @@ fn item_identity_properties() -> Value {
"cognitive_process": cognitive_process(),
"format": {
"type": "string",
"enum": ["single_best_answer", "multiple_response", "true_false"],
"description": "single_best_answer requires exactly one keyed option; \
multiple_response requires at least two."
"enum": ["single_best_answer", "multiple_response", "true_false", "open_response"],
"description": "single_best_answer keys exactly one option; multiple_response keys \
two or more; open_response takes no options and is graded from its \
solution."
},
"bonus": { "type": "boolean" },
"points": { "type": "number", "exclusiveMinimum": 0.0 },
@@ -767,8 +839,10 @@ fn item_content_properties() -> Value {
"type": "array",
"minItems": 2,
"maxItems": 8,
"description": "Absent for an open_response item; at least two for any choice format.",
"items": option_schema()
},
"solution": solution_schema(),
"learning_objectives": string_array(
"Objective ids this item measures. Reports aggregate on these, so an item with none \
contributes to nothing."
@@ -807,7 +881,7 @@ fn item_schema() -> Value {
}
json!({
"type": "object",
"required": ["id", "level", "stem", "options"],
"required": ["id", "level", "stem"],
"additionalProperties": false,
"properties": Value::Object(properties)
})
+10 -4
View File
@@ -421,7 +421,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
});
};
// ---------------------------------------------------------------- clarity
// --- clarity
let stem = it.stem.trim();
let stem_lower = stem.to_lowercase();
let words: Vec<&str> = stem.split_whitespace().collect();
@@ -513,7 +513,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
);
}
// ----------------------------------------------------------------- cueing
// --- cueing
let keys: Vec<&crate::item::Choice> = it.options.iter().filter(|o| o.correct).collect();
let distractors: Vec<&crate::item::Choice> = it.options.iter().filter(|o| !o.correct).collect();
@@ -659,7 +659,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
}
}
// ----------------------------------------------------------- completeness
// --- completeness
// These are only worth insisting on once an item is meant to be used.
let is_ready = matches!(it.status, Status::Approved | Status::InReview);
if is_ready {
@@ -749,7 +749,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
}
}
// --------------------------------------------------------------- evidence
// --- evidence
if !it.calibration_is_current() {
push(
Rule::StaleCalibration,
@@ -868,6 +868,12 @@ fn lint_option_counts(catalog: &Catalog) -> Vec<Finding> {
if e.item.status == Status::Retired {
continue;
}
// An open-response item carries no options, so it is neither part of the
// count norm nor able to deviate from it. Leaving it out keeps a bank of
// four-option questions from reporting every essay as an odd count.
if !e.item.has_options() {
continue;
}
by_bank.entry(e.bank.as_str()).or_default().push(e);
}
+4 -4
View File
@@ -84,7 +84,7 @@ pub fn select(
let seed = blueprint.seed.unwrap_or(0);
let mut notes = Vec::new();
// ------------------------------------------------------------------ pool
// --- pool
let eligible: Vec<&crate::catalog::Entry> = catalog
.assemblable()
.into_iter()
@@ -101,7 +101,7 @@ pub fn select(
let mut chosen: Vec<String> = Vec::new();
let mut per_bank: BTreeMap<String, usize> = BTreeMap::new();
// ---------------------------------------------------- objective minimums
// --- objective minimums
// Placed first, because a coverage requirement is the constraint most likely
// to become unsatisfiable once the level quotas are full.
for (objective, needed) in &blueprint.objective_minimums {
@@ -140,7 +140,7 @@ pub fn select(
}
}
// ------------------------------------------------------- level quotas
// --- level quotas
let mut scored: Vec<String> = Vec::new();
for (level, want) in &blueprint.level_counts {
if *want == 0 {
@@ -175,7 +175,7 @@ pub fn select(
}
}
// ------------------------------------------------------------ bonus items
// --- bonus items
let mut bonus: Vec<String> = Vec::new();
for (level, want) in &blueprint.bonus_counts {
if *want == 0 {
+23
View File
@@ -412,6 +412,29 @@ pub(crate) enum ExportCommand {
#[arg(long)]
out: Option<PathBuf>,
},
/// Write a Quarto worksheet and a matching solutions document.
///
/// The worksheet holds the questions and nothing else; the solutions document
/// adds the key, the worked reasoning, the rubric, and the readings to revisit.
/// This is the path that does not go through Canvas, so a student can practice
/// from the `.qmd` and check themselves against the solutions. Render each with
/// `quarto render <file>.qmd`.
Practice {
/// Assessment id.
id: String,
/// Which form's ordering to use.
#[arg(long, default_value = "A")]
form: String,
/// Which documents to write; defaults to both. Values: worksheet, solutions.
#[arg(long, value_name = "DOC")]
variant: Vec<String>,
/// Output directory; defaults to build/.
#[arg(long)]
out: Option<PathBuf>,
/// Do not leave written-answer space after open-response questions.
#[arg(long)]
no_answer_space: bool,
},
}
#[derive(Debug, Subcommand)]
+57
View File
@@ -12,6 +12,7 @@
use coursebank::assessment::Form;
use coursebank::error::{Error, Result};
use coursebank::layout::Layout;
use coursebank::practice;
use coursebank::qti;
use coursebank::typst;
use coursebank::yaml;
@@ -168,7 +169,63 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
println!("wrote {}", path.display());
Ok(Outcome::Ok)
}
ExportCommand::Practice {
id,
form,
variant,
out,
no_answer_space,
} => {
let record = load_record(&catalog, id)?;
let form = pick_form(&record, form)?;
let dir = out.clone().unwrap_or(build);
for v in pick_practice_variants(variant)? {
let opts = practice::Options {
form: form.clone(),
variant: v,
answer_space: !no_answer_space,
};
let text = practice::render(&catalog, &record, &opts)?;
let path = dir.join(format!("{id}-{}{}.qmd", form.id, v.suffix()));
yaml::write_text(&path, &text)?;
println!("wrote {}", path.display());
}
if !cli.quiet {
println!("\nRender with: quarto render <file>.qmd");
}
Ok(Outcome::Ok)
}
}
}
/// Resolves the `--variant` flags for `export practice`, defaulting to both.
///
/// # Arguments
///
/// * `names` - the raw flag values, possibly empty.
///
/// # Returns
///
/// The documents to write, deduplicated and in canonical order (worksheet first).
///
/// # Errors
///
/// Returns [`Error::Usage`] naming the valid tokens.
fn pick_practice_variants(names: &[String]) -> Result<Vec<practice::Variant>> {
if names.is_empty() {
return Ok(practice::Variant::ALL.to_vec());
}
let mut wanted = Vec::new();
for name in names {
let variant = practice::Variant::parse(name)?;
if !wanted.contains(&variant) {
wanted.push(variant);
}
}
Ok(practice::Variant::ALL
.into_iter()
.filter(|v| wanted.contains(v))
.collect())
}
/// Resolves the `--variant` flags, defaulting to every document.
+2
View File
@@ -8,6 +8,7 @@
//! |:--|:--|:--|
//! | [`qti`] | a QTI 1.2 zip | importing into Canvas |
//! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet |
//! | [`practice`] | Quarto Markdown | a worksheet and a solutions document, off Canvas |
//! | [`report`] | Markdown and HTML | students, and yourself |
//! | [`lecture`] | Markdown | the reading list on the course website |
//!
@@ -22,6 +23,7 @@
//! The instructor report answers "what should I fix?" and holds the item statistics.
pub mod lecture;
pub mod practice;
pub mod qti;
pub mod report;
pub mod typst;
+669
View File
@@ -0,0 +1,669 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Rendering an assessment as a Quarto worksheet a student can work through, and
//! a matching solutions document they can learn from.
//!
//! This is the path that does not go through Canvas. You assemble a homework,
//! quiz, or practice set the same way you assemble an exam, then render it as two
//! `.qmd` files: [`Variant::Worksheet`] holds the questions and nothing else, and
//! [`Variant::Solutions`] holds the same questions with the key marked, the worked
//! reasoning, the rubric for anything open-ended, and where to read again. A
//! student with neither the Canvas quiz nor the printed exam can still practice
//! from the worksheet and check themselves against the solutions.
//!
//! A worksheet never contains the answer. It is built only from stems and
//! options, and the option letters are the printed positions, so the document has
//! nothing in it to leak: not a `correct` flag, not a solution, not a rationale.
//! [`Variant::Solutions`] is a separate render from the same input.
//!
//! Option order comes from the form's seed. When a form shuffles, both
//! documents relabel to the printed order through
//! [`select::option_order`], so a worksheet handed to
//! a student who saw form B agrees with the form B solutions.
//!
//! Everything a solution shows is authored: the model answer, the explanation, the
//! per-option notes, the rubric, and the review citations. Nothing is invented
//! here. A question with an empty [`crate::item::Solution`] renders its key and
//! stops, which is a visible cue to go finish writing it.
use crate::assessment::{AssessmentFile, Form, Placement};
use crate::catalog::Catalog;
use crate::course::{CourseFile, Reference};
use crate::error::Result;
use crate::item::{Choice, Citation, Item};
use crate::markup;
use crate::select;
/// Which of the two documents to render.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Variant {
/// Questions only, for a student to work through.
#[default]
Worksheet,
/// Questions with the key, worked solutions, rubric, and readings.
Solutions,
}
impl Variant {
/// Both documents, in the order they are usually written.
pub const ALL: [Variant; 2] = [Variant::Worksheet, Variant::Solutions];
/// The token used on the command line and in a file name.
pub fn as_str(self) -> &'static str {
match self {
Variant::Worksheet => "worksheet",
Variant::Solutions => "solutions",
}
}
/// The suffix a generated file name carries, e.g. `-solutions`.
pub fn suffix(self) -> &'static str {
match self {
Variant::Worksheet => "",
Variant::Solutions => "-solutions",
}
}
/// The word for this document in a title.
fn title_word(self) -> &'static str {
match self {
Variant::Worksheet => "Questions",
Variant::Solutions => "Solutions",
}
}
/// Parses a `--variant` value.
///
/// # Arguments
///
/// * `name` - the token, case insensitive; `questions` is accepted for the
/// worksheet and `key` for the solutions, since those are what people type.
///
/// # Returns
///
/// The variant.
///
/// # Errors
///
/// Returns [`crate::error::Error::Usage`] naming the valid tokens.
pub fn parse(name: &str) -> Result<Variant> {
match name.trim().to_ascii_lowercase().as_str() {
"worksheet" | "questions" | "q" => Ok(Variant::Worksheet),
"solutions" | "solution" | "key" => Ok(Variant::Solutions),
other => Err(crate::error::Error::usage(format!(
"unknown practice document `{other}`; use worksheet or solutions"
))),
}
}
}
/// What to render.
#[derive(Debug, Clone)]
pub struct Options {
/// Which form's ordering to use. Defaults to an unshuffled form.
pub form: Form,
/// Which document.
pub variant: Variant,
/// Leave vertical space after each question on the worksheet for a written
/// answer. Ignored for the solutions document.
pub answer_space: bool,
}
impl Default for Options {
fn default() -> Options {
Options {
form: Form {
id: "A".to_string(),
seed: 0,
shuffle_items: false,
shuffle_options: false,
},
variant: Variant::Worksheet,
answer_space: true,
}
}
}
impl Options {
/// Options for one variant on one form.
///
/// # Arguments
///
/// * `variant` - which document.
/// * `form` - the form whose ordering to use.
///
/// # Returns
///
/// The options, with the answer space on.
pub fn new(variant: Variant, form: Form) -> Options {
Options {
form,
variant,
answer_space: true,
}
}
}
/// Renders the questions-only worksheet.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `form` - the form whose ordering to use.
///
/// # Returns
///
/// The Quarto Markdown, ending in a newline.
///
/// # Errors
///
/// Returns [`crate::error::Error::Unresolved`] when a placement references a
/// missing item.
pub fn worksheet(catalog: &Catalog, record: &AssessmentFile, form: &Form) -> Result<String> {
render(
catalog,
record,
&Options::new(Variant::Worksheet, form.clone()),
)
}
/// Renders the solutions document.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `form` - the form whose ordering to use.
///
/// # Returns
///
/// The Quarto Markdown, ending in a newline.
///
/// # Errors
///
/// As [`worksheet`].
pub fn solutions(catalog: &Catalog, record: &AssessmentFile, form: &Form) -> Result<String> {
render(
catalog,
record,
&Options::new(Variant::Solutions, form.clone()),
)
}
/// Renders one document.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `opts` - what to render.
///
/// # Returns
///
/// The Quarto Markdown, ending in a newline.
///
/// # Errors
///
/// Returns [`crate::error::Error::Unresolved`] when a placement references a
/// missing item.
pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<String> {
let course = &catalog.course;
let mut out = front_matter(course, record, opts.variant);
if let Some(instructions) = &record.assessment.instructions {
out.push_str(&markup::to_markdown(instructions));
out.push_str("\n\n");
}
// One shared stimulus is printed once, above the first question that uses it,
// so a testlet reads as a block rather than repeating the vignette per item.
let mut printed_stimulus: Option<String> = None;
let layout = select::layout(record, &opts.form);
for (position, placement) in layout.iter().filter(|p| !p.dropped).enumerate() {
let entry = catalog.require(&placement.item)?;
let item = &entry.item;
let number = position + 1;
if let Some(stimulus_id) = &item.stimulus {
if printed_stimulus.as_deref() != Some(stimulus_id.as_str()) {
if let Some(stimulus) = course.stimuli.get(stimulus_id) {
out.push_str("::: {.stimulus}\n\n");
out.push_str(&markup::to_markdown(&stimulus.body));
out.push_str("\n\n:::\n\n");
}
printed_stimulus = Some(stimulus_id.clone());
}
}
match opts.variant {
Variant::Worksheet => worksheet_question(
&mut out,
number,
placement,
item,
&opts.form,
opts.answer_space,
),
Variant::Solutions => {
solution_question(&mut out, number, placement, item, &opts.form, course)
}
}
}
Ok(out)
}
/// The Quarto YAML front matter.
fn front_matter(course: &CourseFile, record: &AssessmentFile, variant: Variant) -> String {
let title = format!("{}: {}", record.assessment.title, variant.title_word());
let subtitle = format!("{} · {}", course.course.code, course.course.title);
let mut out = String::from("---\n");
out.push_str(&format!("title: \"{}\"\n", yaml_quote(&title)));
out.push_str(&format!("subtitle: \"{}\"\n", yaml_quote(&subtitle)));
if let Some(date) = record.assessment.date {
out.push_str(&format!("date: \"{date}\"\n"));
}
out.push_str("format:\n html:\n toc: false\n number-sections: false\n");
out.push_str("---\n\n");
out
}
/// One question on the worksheet: stem, options in printed order, no answer.
fn worksheet_question(
out: &mut String,
number: usize,
placement: &Placement,
item: &Item,
form: &Form,
answer_space: bool,
) {
out.push_str(&heading(number, placement));
out.push_str(&markup::to_markdown(&item.stem));
out.push_str("\n\n");
if item.has_options() {
let ordered = ordered_options(item, form, &placement.item);
for (position, source) in ordered.iter().enumerate() {
out.push_str(&format!(
"{}. {}\n",
letter(position),
markup::to_markdown(&source.text)
));
}
out.push('\n');
} else if answer_space {
// A place to write, sized by the theme, present only when asked for.
out.push_str("::: {.answer-space}\n:::\n\n");
}
}
/// One question in the solutions document: stem, key, worked reasoning, rubric,
/// and where to look again.
fn solution_question(
out: &mut String,
number: usize,
placement: &Placement,
item: &Item,
form: &Form,
course: &CourseFile,
) {
out.push_str(&heading(number, placement));
out.push_str(&meta_line(placement, item));
out.push_str(&markup::to_markdown(&item.stem));
out.push_str("\n\n");
if item.has_options() {
let ordered = ordered_options(item, form, &placement.item);
for (position, source) in ordered.iter().enumerate() {
let mark = if source.correct { "" } else { "" };
let note = source
.student_text()
.map(|t| format!(": {}", markup::to_markdown(t)))
.unwrap_or_default();
out.push_str(&format!(
"{}. {}{mark}{note}\n",
letter(position),
markup::to_markdown(&source.text)
));
}
out.push('\n');
}
solution_body(out, item);
objectives_line(out, item, course);
review_line(out, item, course);
out.push('\n');
}
/// The model answer, explanation, rubric, and accepted answers, when present.
fn solution_body(out: &mut String, item: &Item) {
let Some(solution) = item.solution.as_ref().filter(|s| !s.is_empty()) else {
if !item.has_options() {
// An open-response question with no written solution is unfinished, and
// saying so in the document is more useful than a silent blank.
out.push_str("_No solution written yet._\n\n");
}
return;
};
if let Some(answer) = &solution.model_answer {
out.push_str(&format!(
"**Model answer.** {}\n\n",
markup::to_markdown(answer)
));
}
if let Some(explanation) = &solution.explanation {
out.push_str(&markup::to_markdown(explanation));
out.push_str("\n\n");
}
if !solution.rubric.is_empty() {
out.push_str("**Rubric**\n\n");
for criterion in &solution.rubric {
let points = criterion
.points
.map(|p| format!(" ({} pt)", trim_number(p)))
.unwrap_or_default();
out.push_str(&format!(
"- {}{points}\n",
markup::to_markdown(&criterion.description)
));
}
out.push('\n');
}
if !solution.accepted.is_empty() {
let joined: Vec<String> = solution
.accepted
.iter()
.map(|a| markup::to_markdown(a))
.collect();
out.push_str(&format!("**Accepted answers:** {}\n\n", joined.join("; ")));
}
}
/// The `Tests:` line naming the objectives this item measures.
fn objectives_line(out: &mut String, item: &Item, course: &CourseFile) {
if item.learning_objectives.is_empty() {
return;
}
let texts: Vec<String> = item
.learning_objectives
.iter()
.map(|id| course.objective_text(id))
.collect();
out.push_str(&format!("**Tests:** {}\n\n", texts.join("; ")));
}
/// The `Review:` line, resolving each citation to a short label, linked when a URL
/// resolves.
fn review_line(out: &mut String, item: &Item, course: &CourseFile) {
let Some(solution) = item.solution.as_ref() else {
return;
};
if solution.review.is_empty() {
return;
}
let cites: Vec<String> = solution.review.iter().map(|c| cite(course, c)).collect();
out.push_str(&format!("**Review:** {}\n\n", cites.join("; ")));
}
/// Resolves one citation to Markdown, mirroring the lecture reading style
/// `` `KKW` [§6.1](url) ``.
fn cite(course: &CourseFile, citation: &Citation) -> String {
if let Some(text) = &citation.text {
if citation.reference.is_none() {
return text.clone();
}
}
let Some(key) = &citation.reference else {
return citation.display();
};
let Some(reference) = course.references.get(key) else {
return citation.display();
};
let label = reference.label.as_deref().unwrap_or(key);
let locator = citation.locator.as_deref().unwrap_or("");
match resolve_url(citation, reference) {
Some(url) if !locator.is_empty() => format!("`{label}` [{locator}]({url})"),
Some(url) => format!("`{label}` [{}]({url})", reference.title),
None if !locator.is_empty() => format!("`{label}` {locator}"),
None => format!("`{label}`"),
}
}
/// The URL for a citation: its own `url`, else the reference `base_url` joined with
/// the citation `path`.
fn resolve_url(citation: &Citation, reference: &Reference) -> Option<String> {
if let Some(url) = &citation.url {
return Some(url.clone());
}
let path = citation.path.as_deref()?;
let base = reference.base_url.as_deref()?;
Some(match (base.ends_with('/'), path.starts_with('/')) {
(true, true) => format!("{base}{}", &path[1..]),
(false, false) => format!("{base}/{path}"),
_ => format!("{base}{path}"),
})
}
/// The `## Question N` heading, marking a bonus item.
fn heading(number: usize, placement: &Placement) -> String {
let bonus = if placement.bonus { " (bonus)" } else { "" };
format!("## Question {number}{bonus}\n\n")
}
/// The italic level-and-points line under a solutions heading.
fn meta_line(placement: &Placement, item: &Item) -> String {
let level = placement.level.unwrap_or(item.level);
let mut parts = vec![format!("Level {} ({})", level.code(), level.name())];
if let Some(points) = placement.points {
parts.push(format!("{} point(s)", trim_number(points)));
}
format!("_{}_\n\n", parts.join(" · "))
}
/// The options in the order the form prints them.
///
/// Salted with the item's global id, the same value the Typst and QTI exports use,
/// so a worksheet built for form B lists options in the order that form's paper and
/// its Canvas quiz do.
fn ordered_options<'a>(item: &'a Item, form: &Form, uid: &str) -> Vec<&'a Choice> {
select::option_order(form, uid, item.options.len())
.into_iter()
.map(|i| &item.options[i])
.collect()
}
/// The printed letter for a zero-based position.
fn letter(position: usize) -> char {
(b'A' + (position as u8 % 26)) as char
}
/// Formats a point value without a trailing `.0`.
fn trim_number(value: f64) -> String {
if value.fract() == 0.0 {
format!("{}", value as i64)
} else {
let s = format!("{value:.2}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
}
/// Escapes a double quote for a YAML double-quoted scalar.
fn yaml_quote(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assessment::{Assessment, Kind, Platform};
/// Writes a course and a bank to a temp directory and loads them, the same way
/// the catalog tests do, so this exercises only public API. The `tag` keeps each
/// test in its own directory, so tests running in parallel do not clobber a
/// shared `course.yaml`.
fn catalog(tag: &str) -> Catalog {
let dir = std::env::temp_dir().join(format!("cb-practice-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("banks")).unwrap();
std::fs::write(
dir.join("course.yaml"),
r#"
course: { code: BIOSC 1000, title: Biochemistry, term: 2026f }
references:
kkw:
label: KKW
title: The molecules of life
base_url: https://example.org/kkw/
lectures:
L1.1: { title: Enthalpy }
learning_objectives:
lo-enthalpy:
text: Define enthalpy and explain the constant-pressure result.
lectures: [L1.1]
order: 1
"#,
)
.unwrap();
std::fs::write(
dir.join("banks").join("l11.yaml"),
r#"
bank: { id: l11, title: L1.1 }
items:
- id: q-enthalpy-001
status: draft
level: 1
stem: At constant pressure, the heat exchanged equals which quantity?
learning_objectives: [lo-enthalpy]
options:
- { id: A, text: "the enthalpy change", correct: true, feedback_student: "Right: P dV work is folded into H." }
- { id: B, text: "the internal energy change", misconception: "ignores expansion work" }
- { id: C, text: "zero" }
solution:
explanation: "Because H = U + PV, at constant P the P dV term is the expansion work, so q_p equals the change in H."
review:
- { ref: kkw, locator: "§6.4", path: "6/A/#4" }
- id: q-enthalpy-op-001
status: draft
level: 2
format: open_response
stem: Explain why, at constant pressure, the heat exchanged equals the enthalpy change.
learning_objectives: [lo-enthalpy]
solution:
model_answer: "At constant pressure the P dV expansion work is folded into H = U + PV, so q_p is the change in H."
rubric:
- { description: "states H = U + PV", points: 1 }
- { description: "identifies q_p with the enthalpy change", points: 1 }
review:
- { ref: kkw, locator: "§6.4", path: "6/A/#4" }
"#,
)
.unwrap();
Catalog::load(&dir).expect("catalog loads")
}
fn record() -> AssessmentFile {
AssessmentFile {
schema_version: "1.0".into(),
assessment: Assessment {
id: "hw-1".into(),
title: "Homework 1".into(),
term: None,
kind: Kind::Homework,
date: None,
platform: Platform::Canvas,
minutes_allowed: None,
attempts: None,
shuffle: None,
scoring_policy: None,
instructions: None,
notes: None,
},
blueprint: None,
forms: Vec::new(),
items: vec![
Placement {
number: 1,
item: "l11::q-enthalpy-001".into(),
version: None,
fingerprint: None,
points: Some(1.0),
bonus: false,
key: vec!["A".into()],
level: None,
learning_objectives: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
},
Placement {
number: 2,
item: "l11::q-enthalpy-op-001".into(),
version: None,
fingerprint: None,
points: Some(2.0),
bonus: false,
key: Vec::new(),
level: None,
learning_objectives: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
},
],
}
}
#[test]
fn worksheet_withholds_the_answer() {
let md =
worksheet(&catalog("worksheet"), &record(), &Options::default().form).expect("renders");
assert!(md.contains("## Question 1"));
assert!(md.contains("A. the enthalpy change"));
// Nothing that reveals the key or the reasoning.
assert!(!md.contains('✓'), "no check marks on the worksheet:\n{md}");
assert!(!md.contains("Model answer"), "no model answer:\n{md}");
assert!(!md.contains("P dV"), "no explanation:\n{md}");
assert!(!md.contains("Rubric"));
// The open-response question leaves room to write.
assert!(md.contains("answer-space"));
}
#[test]
fn solutions_show_key_reasoning_rubric_and_review() {
let md =
solutions(&catalog("solutions"), &record(), &Options::default().form).expect("renders");
assert!(md.contains("A. the enthalpy change ✓"));
assert!(md.contains("the internal energy change: ignores expansion work"));
assert!(md.contains("**Model answer.**"));
assert!(md.contains("H = U + PV"));
assert!(md.contains("**Rubric**"));
assert!(md.contains("states H = U + PV (1 pt)"));
assert!(md.contains("Tests:** Define enthalpy"));
// The review citation resolves to the label and a link.
assert!(
md.contains("`KKW` [§6.4](https://example.org/kkw/6/A/#4)"),
"{md}"
);
}
#[test]
fn front_matter_titles_each_document() {
let ws = worksheet(
&catalog("front-matter-ws"),
&record(),
&Options::default().form,
)
.expect("renders");
assert!(ws.contains("title: \"Homework 1: Questions\""));
let sol = solutions(
&catalog("front-matter-sol"),
&record(),
&Options::default().form,
)
.expect("renders");
assert!(sol.contains("title: \"Homework 1: Solutions\""));
}
}
+107 -4
View File
@@ -47,9 +47,9 @@ const IMSMD_NS: &str = "http://www.imsglobal.org/xsd/imsmd_v1p2";
const IMSCP_SCHEMA: &str = "http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd \
http://www.imsglobal.org/xsd/imsmd_v1p2 imsmd_v1p2p2.xsd";
// ---------------------------------------------------------------------------
// ---
// A very small XML tree
// ---------------------------------------------------------------------------
// ---
/// One XML element.
#[derive(Debug, Clone)]
@@ -155,9 +155,9 @@ fn escape_attr(s: &str) -> String {
escape_text(s).replace('"', "&quot;")
}
// ---------------------------------------------------------------------------
// ---
// Package construction
// ---------------------------------------------------------------------------
// ---
/// Options for a QTI export.
#[derive(Debug, Clone)]
@@ -338,6 +338,11 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> R
///
/// The element.
fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &QtiOptions) -> Node {
// An open-response item is an essay in Canvas: no choices, graded by hand.
if !item.format.has_options() {
return build_essay_item(assessment_id, uid, item, points, opts);
}
let order = select::option_order(&opts.form, uid, item.options.len());
let ordered: Vec<&crate::item::Choice> = order.iter().map(|i| &item.options[*i]).collect();
@@ -499,6 +504,104 @@ fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &Q
node
}
/// Builds a Canvas essay item for an open-response question.
///
/// An essay has no choices and no automatic score: the `<other/>` condition leaves
/// grading to the instructor. When feedback is on and a model answer exists, it
/// rides along as general feedback so a student sees it after submitting.
///
/// This mapping has not been round-tripped through a live Canvas import in this
/// build, so verify it against your instance before relying on it for a graded
/// quiz.
///
/// # Arguments
///
/// * `assessment_id` - salts the generated ids.
/// * `uid` - the item's global id.
/// * `item` - the item.
/// * `points` - points as administered.
/// * `opts` - export options.
///
/// # Returns
///
/// The element.
fn build_essay_item(
assessment_id: &str,
uid: &str,
item: &Item,
points: f64,
opts: &QtiOptions,
) -> Node {
let item_meta = Node::new("itemmetadata").child(Node::new("qtimetadata").children(vec![
metadata_field("question_type", item.format.qti_type()),
metadata_field("points_possible", &format!("{points:.2}")),
metadata_field("assessment_question_identifierref", &qti_id(uid)),
]));
let presentation = Node::new("presentation")
.child(mattext(&format!(
"<div>{}</div>",
markup::to_html(&item.stem)
)))
.child(
Node::new("response_str")
.attr("ident", "response1")
.attr("rcardinality", "Single")
.child(
Node::new("render_fib").child(
Node::new("response_label")
.attr("ident", "answer1")
.attr("rshuffle", "No"),
),
),
);
// The model answer, shown as general feedback after submission.
let model = item
.solution
.as_ref()
.and_then(|s| s.model_answer.as_deref())
.filter(|_| opts.include_feedback);
let mut condition = Node::new("respcondition")
.attr("continue", "No")
.child(Node::new("conditionvar").child(Node::new("other")));
if model.is_some() {
condition = condition.child(
Node::new("displayfeedback")
.attr("feedbacktype", "Response")
.attr("linkrefid", "general_fb"),
);
}
let resprocessing = Node::new("resprocessing")
.child(
Node::new("outcomes").child(
Node::new("decvar")
.attr("maxvalue", "100")
.attr("minvalue", "0")
.attr("varname", "SCORE")
.attr("vartype", "Decimal"),
),
)
.child(condition);
let mut node = Node::new("item")
.attr("ident", qti_id(&format!("{assessment_id}/{uid}")))
.attr("title", item.display_title())
.child(item_meta)
.child(presentation)
.child(resprocessing);
if let Some(text) = model {
node = node.child(Node::new("itemfeedback").attr("ident", "general_fb").child(
Node::new("flow_mat").child(mattext(&format!("<div>{}</div>", markup::to_html(text)))),
));
}
node
}
/// A `<material><mattext texttype="text/html">` pair.
///
/// # Arguments
+11 -11
View File
@@ -98,7 +98,7 @@ pub fn student(
));
out.push_str(&format!("**{}**\n\n", summary.display_name()));
// ---------------------------------------------------------------- score
// --- score
out.push_str(&format!(
"You scored **{:.1} of {:.1} points ({:.0}%)**",
summary.points, summary.points_possible, summary.percent
@@ -144,7 +144,7 @@ pub fn student(
}
}
// ----------------------------------------------------------- objectives
// --- objectives
if opts.objectives && !summary.objectives.is_empty() {
out.push_str("## What this exam says about each learning objective\n\n");
out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n");
@@ -180,7 +180,7 @@ pub fn student(
}
}
// --------------------------------------------------------------- levels
// --- levels
if opts.levels && summary.levels.len() > 1 {
out.push_str("## Kinds of thinking\n\n");
out.push_str(
@@ -231,7 +231,7 @@ pub fn student(
}
}
// ---------------------------------------------------------- what to do
// --- what to do
if !summary.focus.is_empty() {
out.push_str("## Where to put your time\n\n");
out.push_str("In this order:\n\n");
@@ -274,7 +274,7 @@ pub fn student(
out.push_str(&format!("You have clearly got {}.\n\n", list(&refs)));
}
// --------------------------------------------------------- missed items
// --- missed items
if opts.missed && !summary.missed.is_empty() {
out.push_str("## Question by question\n\n");
out.push_str(
@@ -356,7 +356,7 @@ pub fn cohort(
.unwrap_or_else(|| "date not recorded".into())
));
// ------------------------------------------------------------- summary
// --- summary
let r = &analysis.reliability;
out.push_str("## Summary\n\n");
out.push_str(&format!(
@@ -408,7 +408,7 @@ pub fn cohort(
out.push_str(&format!("> {w}\n\n"));
}
// ------------------------------------------------------- revise queue
// --- revise queue
let queue = analysis.revise_queue();
out.push_str("## What to revise\n\n");
if queue.is_empty() {
@@ -477,7 +477,7 @@ pub fn cohort(
}
}
// ---------------------------------------------------------- item table
// --- item table
out.push_str("## Every item\n\n");
out.push_str(
"| Q | Item | Lv | p | r | D | Blank | Flags |\n|--:|:--|--:|--:|--:|--:|--:|:--|\n",
@@ -535,7 +535,7 @@ pub fn cohort(
}
}
// -------------------------------------------------------- class gaps
// --- class gaps
out.push_str("## Objectives the class did not meet\n\n");
if cohort.class_gaps.is_empty() {
out.push_str("Every assessed objective cleared the mastery threshold.\n\n");
@@ -556,7 +556,7 @@ pub fn cohort(
out.push('\n');
}
// ------------------------------------------------------ level coverage
// --- level coverage
out.push_str("## Coverage and class performance by level\n\n");
let counts = record.level_counts();
out.push_str("| Level | Items | Class rate |\n|:--|--:|--:|\n");
@@ -587,7 +587,7 @@ pub fn cohort(
let _ = bp;
}
// -------------------------------------------------------- archetypes
// --- archetypes
if !cohort.archetypes.is_empty() {
out.push_str("## Patterns across students\n\n");
out.push_str(
+1 -5
View File
@@ -662,11 +662,7 @@ fn calibration(item: &Item) -> Option<BTreeMap<String, f64>> {
/// The token for a response format.
fn format_token(format: Format) -> &'static str {
match format {
Format::SingleBestAnswer => "single_best_answer",
Format::MultipleResponse => "multiple_response",
Format::TrueFalse => "true_false",
}
format.as_str()
}
/// The token for an administration platform.
+1 -1
View File
@@ -110,7 +110,7 @@ pub use data::{canvas, gradescope, responses, store};
pub use analysis::{calibrate, classical, irt, students};
pub use export::{lecture, qti, report, typst};
pub use export::{lecture, practice, qti, report, typst};
pub use catalog::Catalog;
pub use course::{CourseFile, SCHEMA_VERSION};
+94 -9
View File
@@ -352,13 +352,23 @@ fn validate_item(
issues.push("version must be at least 1".into());
}
// --- options -----------------------------------------------------------
// --- options ---
// An open-response item takes no options; its answer lives in `solution`.
// Every other format needs at least two things to choose between.
if it.format.has_options() {
if it.options.len() < 2 {
issues.push(format!(
"needs at least 2 options, has {}",
it.options.len()
));
}
} else if !it.options.is_empty() {
issues.push(format!(
"{} items take no options, but {} were given; put the answer in `solution`",
it.format.as_str(),
it.options.len()
));
}
let mut seen: Vec<&str> = Vec::new();
for (i, o) in it.options.iter().enumerate() {
let pos = i + 1;
@@ -418,7 +428,7 @@ fn validate_item(
}
}
// --- key ---------------------------------------------------------------
// --- key -----
let keys = it.key_indices();
match it.format {
Format::SingleBestAnswer => {
@@ -448,9 +458,14 @@ fn validate_item(
issues.push("true_false needs exactly one keyed option".into());
}
}
Format::OpenResponse => {
if !keys.is_empty() {
issues.push("open_response items have no keyed option".into());
}
}
}
// --- level and process must agree -------------------------------------
// --- level and process must agree --------
if let Some(p) = it.cognitive_process {
if !it.level.allows(p) {
issues.push(format!(
@@ -461,7 +476,7 @@ fn validate_item(
}
}
// --- design plausibility ----------------------------------------------
// --- design plausibility -----------
if let Some(d) = &it.design {
if let Some(x) = d.expected_difficulty {
if !(0.0..=1.0).contains(&x) {
@@ -479,7 +494,7 @@ fn validate_item(
}
}
// --- calibration plausibility -----------------------------------------
// --- calibration plausibility ------
if let Some(c) = &it.calibration {
if let Some(p) = c.p_value {
if !(0.0..=1.0).contains(&p) {
@@ -514,7 +529,7 @@ fn validate_item(
}
}
// --- history must be coherent -----------------------------------------
// --- history must be coherent ------
let mut last_version = 0u32;
for (i, h) in it.history.iter().enumerate() {
if h.version <= last_version {
@@ -533,7 +548,7 @@ fn validate_item(
));
}
// --- retirement -------------------------------------------------------
// --- retirement ----
if it.retired.is_some() && it.status != Status::Retired {
issues.push(format!(
"has a `retired` block but status is `{}`",
@@ -541,7 +556,7 @@ fn validate_item(
));
}
// --- approval gate ----------------------------------------------------
// --- approval gate -------
// Approval is what permits an item onto a graded assessment, so it is the
// right place to require that the item is fully sourced and designed.
if it.status == Status::Approved {
@@ -557,9 +572,23 @@ fn validate_item(
if it.design.is_none() {
issues.push("approved items must carry a design block".into());
}
// An open-response item is graded from its solution, so approving one with
// neither a model answer nor a rubric would leave nothing to mark it by.
if !it.format.has_options() {
let gradeable = it
.solution
.as_ref()
.is_some_and(|s| s.model_answer.is_some() || !s.rubric.is_empty());
if !gradeable {
issues.push(
"approved open_response items need a solution with a model_answer or a rubric"
.into(),
);
}
}
}
// --- cross-file references --------------------------------------------
// --- cross-file references ---------
if let Some(c) = course {
for lo in &it.learning_objectives {
match c.learning_objectives.get(lo) {
@@ -592,6 +621,15 @@ fn validate_item(
issues.push(format!("unknown stimulus `{st}`"));
}
}
// A citation that names a reference key must name a real one, so a review
// pointer in the solutions document never resolves to nothing.
for citation in it.solution.iter().flat_map(|s| &s.review) {
if let Some(key) = &citation.reference {
if !c.references.contains_key(key) {
issues.push(format!("solution.review cites unknown reference `{key}`"));
}
}
}
if let Some(floor) = c.policy.partial_credit_floor_level {
for o in &it.options {
if o.is_partial() && it.level < floor {
@@ -644,6 +682,53 @@ mod tests {
assert!(b.validate(None).is_empty(), "{:?}", b.validate(None));
}
#[test]
fn open_response_validates_without_options_and_rejects_them() {
// No options is fine, and no key is required.
let ok = bank(
r#"
- id: q-a-op-001
status: draft
level: 2
format: open_response
stem: Explain the first law.
solution:
model_answer: Energy is conserved.
"#,
);
assert!(ok.validate(None).is_empty(), "{:?}", ok.validate(None));
// Giving an open-response item options is the mistake, and so is approving
// one with nothing to grade it by.
let bad = bank(
r#"
- id: q-a-op-002
status: approved
level: 2
format: open_response
cognitive_process: explain
learning_objectives: [lo-x]
sources: [{ lecture: L1.1 }]
design: { rationale: r }
stem: Explain the first law.
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
"#,
);
let issues = bad.validate(None);
assert!(
issues.iter().any(|i| i.contains("take no options")),
"{issues:?}"
);
assert!(
issues
.iter()
.any(|i| i.contains("model_answer or a rubric")),
"{issues:?}"
);
}
#[test]
fn catches_missing_and_multiple_keys() {
let b = bank(
+312 -1
View File
@@ -19,7 +19,11 @@
//! it. That keeps bank files readable and reviewable in a pull request while
//! still letting statistics accumulate across terms.
use serde::{Deserialize, Serialize};
use std::fmt;
use serde::de::{self, MapAccess, Visitor};
use serde::ser::SerializeMap;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::date::Date;
use crate::hash::fingerprint;
@@ -79,8 +83,25 @@ pub struct Item {
/// The answer options in canonical order. Shuffling happens at export time
/// per form, never here, so the bank stays diffable.
///
/// Empty for a [`Format::OpenResponse`] item, which is answered in free text
/// and graded from its [`Solution`] instead. A choice format must still supply
/// at least two, which [`crate::bank::BankFile::validate`] enforces; leaving
/// them out is reported there, with every other problem, rather than failing
/// the parse on its own.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<Choice>,
/// The worked solution: a model answer, an explanation a student can learn
/// from, and, for an open-response item, the rubric it is graded against.
///
/// This is what the solutions document renders and what a paper answer key
/// prints. It is withheld from any question paper and from the exam payload,
/// the same way an option's `correct` flag is, so a document built for the
/// student cannot leak it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub solution: Option<Solution>,
/// Objectives this item measures, as ids into the course registry.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec<String>,
@@ -239,6 +260,214 @@ pub struct Source {
pub recording_seconds: Option<u32>,
}
/// A pointer from an item into the course reference registry: where to read more,
/// or what to revisit after missing the item.
///
/// It holds a citation key and a locator rather than a restated citation, so a
/// reference is written once in `course.yaml` and a changed edition is a single
/// edit. The exporters resolve it against
/// [`crate::course::CourseFile::references`] into a short label such as `KKW §6.1`,
/// linked when the location resolves to a URL. This is the same pointer a lecture
/// [`crate::course::Reading`] uses, kept lean here because an item cites a reading;
/// it does not restate one.
///
/// A citation may also be written as a bare string, which lands unparsed in `text`
/// and serializes back out as a string, so a bank that stored readings as plain
/// strings keeps loading and round-trips byte-for-byte.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Citation {
/// Citation key into the course reference registry.
pub reference: Option<String>,
/// Where inside the work: `§6.1`, `pp. 212-219`, `fig. 4`.
pub locator: Option<String>,
/// Appended to the reference's `base_url` to reach this location.
pub path: Option<String>,
/// A full URL, when the location is not under the reference's `base_url`.
pub url: Option<String>,
/// A citation written as a bare string, held unparsed.
pub text: Option<String>,
}
impl Citation {
/// A short display string that needs no reference lookup.
///
/// Prefers the unparsed `text`, then the key and locator. A caller that holds
/// the course, such as an exporter, can resolve a nicer label and a link; this
/// is the fallback for one that does not.
///
/// # Returns
///
/// The display string, empty when the citation carries nothing.
pub fn display(&self) -> String {
if let Some(text) = &self.text {
return text.clone();
}
match (&self.reference, &self.locator) {
(Some(k), Some(l)) => format!("{k} {l}"),
(Some(k), None) => k.clone(),
(None, Some(l)) => l.clone(),
(None, None) => String::new(),
}
}
}
/// Writes a citation as a mapping, or as a bare string when that is all it holds.
impl Serialize for Citation {
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
if let Some(text) = &self.text {
if self.reference.is_none()
&& self.locator.is_none()
&& self.path.is_none()
&& self.url.is_none()
{
return s.serialize_str(text);
}
}
let mut map = s.serialize_map(None)?;
if let Some(v) = &self.reference {
map.serialize_entry("ref", v)?;
}
if let Some(v) = &self.locator {
map.serialize_entry("locator", v)?;
}
if let Some(v) = &self.path {
map.serialize_entry("path", v)?;
}
if let Some(v) = &self.url {
map.serialize_entry("url", v)?;
}
if let Some(v) = &self.text {
map.serialize_entry("text", v)?;
}
map.end()
}
}
/// Accepts a citation written either as a mapping or as a bare string.
impl<'de> Deserialize<'de> for Citation {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Citation, D::Error> {
/// The mapping form, with the field set kept in one place.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Mapping {
#[serde(rename = "ref", default)]
reference: Option<String>,
#[serde(default)]
locator: Option<String>,
#[serde(default)]
path: Option<String>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
text: Option<String>,
}
struct V;
impl<'a> Visitor<'a> for V {
type Value = Citation;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a citation mapping with a `ref`, or a plain citation string")
}
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Citation, E> {
Ok(Citation {
text: Some(v.to_string()),
..Citation::default()
})
}
fn visit_map<M: MapAccess<'a>>(
self,
map: M,
) -> std::result::Result<Citation, M::Error> {
let m = Mapping::deserialize(de::value::MapAccessDeserializer::new(map))?;
Ok(Citation {
reference: m.reference,
locator: m.locator,
path: m.path,
url: m.url,
text: m.text,
})
}
}
d.deserialize_any(V)
}
}
/// One line of a grading rubric for an open-response item.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RubricCriterion {
/// What earns the points, e.g. "states H = U + PV" or "compares to ~2.5 kJ/mol".
pub description: String,
/// Points for this line. Absent lets a grader decide; when present, the lines
/// are meant to sum to the item's point value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub points: Option<f64>,
}
/// The worked solution to an item: what the answer is, why, and how it is graded.
///
/// One place, versioned with the question, holds everything a student learns from
/// after the fact and everything a grader marks an open response against. For a
/// choice item the per-option [`Choice::explanation`] says why each option is right
/// or wrong; the solution adds the single worked line of reasoning a solutions
/// document leads with. For an [`Format::OpenResponse`] item the solution is the
/// whole answer, because there are no options to annotate.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Solution {
/// The model answer, in the authoring markup. For an open-response item this is
/// the response a full-credit student would write; for a choice item it is an
/// optional one-line statement of the key in words.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_answer: Option<String>,
/// The worked reasoning a student can learn from: the derivation, the estimate,
/// the argument for the key over its neighbours. This is the body of the
/// solutions document.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub explanation: Option<String>,
/// How an open response is graded, one criterion per line.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rubric: Vec<RubricCriterion>,
/// Responses a short constructed answer would be accepted as. Shown in the
/// solutions document as accepted answers, and the hook for automated grading
/// later.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub accepted: Vec<String>,
/// Where to look again after missing this item, as citations into the course
/// reference registry. Resolved and linked by the exporters.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub review: Vec<Citation>,
}
impl Solution {
/// Whether the solution carries anything worth rendering.
///
/// Used to decide whether a solutions entry has a body to print, so an item
/// with an empty `solution:` block is treated as having none.
pub fn is_empty(&self) -> bool {
self.model_answer.is_none()
&& self.explanation.is_none()
&& self.rubric.is_empty()
&& self.accepted.is_empty()
&& self.review.is_empty()
}
/// Total of the rubric line points, when every line carries one.
///
/// # Returns
///
/// The sum, or `None` if any line omits its points or the rubric is empty.
pub fn rubric_points(&self) -> Option<f64> {
if self.rubric.is_empty() {
return None;
}
self.rubric.iter().map(|c| c.points).sum::<Option<f64>>()
}
}
/// A figure or data file reproduced with an item.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -473,6 +702,7 @@ impl Item {
stimulus: None,
stem: stem.to_string(),
options,
solution: None,
learning_objectives: Vec::new(),
sources: Vec::new(),
topics: Vec::new(),
@@ -538,6 +768,14 @@ impl Item {
self.key_indices().len() > 1
}
/// Whether the item presents selectable options, per its [`Format`].
///
/// `false` for an [`Format::OpenResponse`] item. Callers that would otherwise
/// index `options` or read a key should branch on this first.
pub fn has_options(&self) -> bool {
self.format.has_options()
}
/// The display title, falling back to a truncated stem.
///
/// # Returns
@@ -821,4 +1059,77 @@ options:
IrtModel::ThreePl
);
}
#[test]
fn open_response_item_parses_without_options() {
let it = item(
r#"
id: q-enthalpy-op-001
status: draft
level: 2
format: open_response
stem: Explain why, at constant pressure, the heat exchanged equals the enthalpy change.
solution:
model_answer: >-
At constant pressure the P dV expansion work is folded into H = U + PV, so the
heat q_p equals the change in H.
rubric:
- { description: "states H = U + PV", points: 1 }
- { description: "identifies q_p with the enthalpy change", points: 1 }
review:
- { ref: kuriyan2012molecules, locator: "§6.4", path: "6/A/#4" }
"#,
);
assert_eq!(it.format, Format::OpenResponse);
assert!(it.options.is_empty());
assert!(!it.has_options());
assert!(it.key_letters().is_empty());
let sol = it.solution.as_ref().expect("has a solution");
assert!(!sol.is_empty());
assert_eq!(sol.rubric_points(), Some(2.0));
assert_eq!(sol.review.len(), 1);
assert_eq!(
sol.review[0].reference.as_deref(),
Some("kuriyan2012molecules")
);
}
#[test]
fn a_solution_serializes_only_what_it_holds() {
let it = item(
r#"
id: q-demo-op-002
status: draft
level: 2
format: open_response
stem: State the first law.
solution:
model_answer: The total energy of an isolated system is constant.
"#,
);
let yaml = serde_yaml_ng::to_string(&it).expect("serializes");
// Open-response items carry no options key, and an empty rubric is omitted.
assert!(!yaml.contains("options:"), "no options key:\n{yaml}");
assert!(!yaml.contains("rubric"), "empty rubric omitted:\n{yaml}");
assert!(yaml.contains("model_answer:"));
}
#[test]
fn a_citation_round_trips_as_string_or_mapping() {
// A bare string stays a bare string.
let bare: Citation = serde_yaml_ng::from_str("\"KKW §6.4 (course reserve)\"").unwrap();
assert_eq!(bare.text.as_deref(), Some("KKW §6.4 (course reserve)"));
assert_eq!(bare.display(), "KKW §6.4 (course reserve)");
let back = serde_yaml_ng::to_string(&bare).unwrap();
assert_eq!(back.trim(), "KKW §6.4 (course reserve)");
// A mapping keeps its fields, and `ref` is the key's YAML spelling.
let mapped: Citation =
serde_yaml_ng::from_str("{ ref: kuriyan2012molecules, locator: \"§6.4\" }").unwrap();
assert_eq!(mapped.reference.as_deref(), Some("kuriyan2012molecules"));
assert_eq!(mapped.display(), "kuriyan2012molecules §6.4");
let back = serde_yaml_ng::to_string(&mapped).unwrap();
assert!(back.contains("ref: kuriyan2012molecules"));
assert!(back.contains("locator:"));
}
}
+50
View File
@@ -423,17 +423,53 @@ pub enum Format {
MultipleResponse,
/// Two options, True and False.
TrueFalse,
/// A free-text answer the student writes rather than selects.
///
/// It carries no options and is not machine-scored. What a grader marks it
/// against, and what a solutions document shows, lives in the item's
/// [`crate::item::Solution`]: a model answer and, when the item is worth more
/// than a point, a rubric. This is the format for "explain", "derive", and
/// "estimate" prompts that a set of distractors would trivialize.
OpenResponse,
}
impl Format {
/// Every response format.
pub const ALL: [Format; 4] = [
Format::SingleBestAnswer,
Format::MultipleResponse,
Format::TrueFalse,
Format::OpenResponse,
];
/// The QTI question type Canvas expects for this format.
pub fn qti_type(self) -> &'static str {
match self {
Format::SingleBestAnswer => "multiple_choice_question",
Format::MultipleResponse => "multiple_answers_question",
Format::TrueFalse => "true_false_question",
Format::OpenResponse => "essay_question",
}
}
/// The snake_case token used in YAML.
pub fn as_str(self) -> &'static str {
match self {
Format::SingleBestAnswer => "single_best_answer",
Format::MultipleResponse => "multiple_response",
Format::TrueFalse => "true_false",
Format::OpenResponse => "open_response",
}
}
/// Whether items in this format present selectable options.
///
/// `false` only for [`Format::OpenResponse`]. Validation, assembly, and the
/// exporters branch on this rather than on the variant, so the day a second
/// free-text format is added it inherits the no-options handling for free.
pub fn has_options(self) -> bool {
!matches!(self, Format::OpenResponse)
}
}
/// How strongly an item is expected to separate strong from weak students.
@@ -633,4 +669,18 @@ mod tests {
Status::InReview
);
}
#[test]
fn open_response_is_the_only_format_without_options() {
assert_eq!(
serde_json::from_str::<Format>("\"open_response\"").unwrap(),
Format::OpenResponse
);
assert_eq!(Format::OpenResponse.as_str(), "open_response");
assert_eq!(Format::OpenResponse.qti_type(), "essay_question");
assert!(!Format::OpenResponse.has_options());
for f in Format::ALL {
assert_eq!(f.has_options(), f != Format::OpenResponse);
}
}
}
+43
View File
@@ -113,6 +113,32 @@ pub fn to_plain(src: &str) -> String {
out.trim().to_string()
}
/// Converts authoring markup to Pandoc-flavoured Markdown, for a Quarto document.
///
/// Subscripts and superscripts become Pandoc's `~x~` and `^x^`, the symbol table
/// renders as Unicode, and the bold, italic, and inline-code spans are already
/// Markdown, so they pass through unchanged. Paragraph breaks are kept. Nothing is
/// HTML-escaped, because the consumer is a Markdown renderer rather than a page.
///
/// # Arguments
///
/// * `src` - the authoring source.
///
/// # Returns
///
/// Pandoc Markdown, trimmed, with paragraph breaks preserved.
pub fn to_markdown(src: &str) -> String {
let symbolized = apply_symbols(src, false);
let mut out = wrap_bracket(&symbolized, "#sub[", "~", "~");
out = wrap_bracket(&out, "#sup[", "^", "^");
out.lines()
.map(|l| l.trim_end())
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string()
}
/// Passes authoring markup through for Typst.
///
/// The markup is already a Typst subset, so this only normalizes whitespace and
@@ -379,4 +405,21 @@ mod tests {
assert_eq!(to_typst("a @ b"), "a \\@ b");
assert_eq!(to_typst("x < y"), "x \\< y");
}
#[test]
fn markdown_uses_pandoc_scripts_and_unicode_symbols() {
assert_eq!(to_markdown("H#sub[2]O"), "H~2~O");
assert_eq!(to_markdown("x#sup[2]"), "x^2^");
assert_eq!(
to_markdown("K#sub[m] #sym.approx 5 mM"),
"K~m~ \u{2248} 5 mM"
);
// Bold, italic, and code are already Markdown.
assert_eq!(
to_markdown("**bold** and *em* and `code`"),
"**bold** and *em* and `code`"
);
// Paragraph breaks survive.
assert_eq!(to_markdown("one\n\ntwo"), "one\n\ntwo");
}
}