feat: initial package draft

This commit is contained in:
2026-08-05 23:18:16 -04:00
parent 66291ba545
commit 09c3222f8d
36 changed files with 21604 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
//! Psychometrics: what the responses say about the items and about the students.
//!
//! ```text
//! responses ──▶ classical ──┬──▶ students ──▶ reports
//! └──▶ irt ───────┤
//! └──▶ calibrate ──▶ back onto the items
//! ```
//!
//! [`classical`] is the one that changes what you do next. The corrected
//! point-biserial answers "did the students who knew the material get this right?",
//! and a negative one almost always means a keying error or a second defensible
//! reading of the stem.
//!
//! [`irt`] adds difficulty-aware ability estimates. Its priors are on by default
//! and that is not a stylistic choice: unpenalized maximum likelihood has no finite
//! solution for an item everyone answered correctly, and real classroom exams
//! contain those routinely.
//!
//! [`students`] turns item statistics into per-objective standing, and is careful
//! about what three questions can honestly support — it classifies on the observed
//! rate and reports confidence from the Wilson interval separately.
//!
//! [`calibrate`] is the arrow back to authoring, and the reason the system
//! compounds: statistics written onto an item are there the next time you consider
//! using it. It writes to [`crate::model::bank`] files, which is the one place this
//! module reaches upward, and it always shows a diff first.
//!
//! Everything here labels its own uncertainty. A point-biserial from twenty-four
//! examinees has a standard error near 0.2, and a tool that reports it to three
//! decimals without saying so is lying by omission.
pub mod calibrate;
pub mod classical;
pub mod irt;
pub mod students;
+857
View File
@@ -0,0 +1,857 @@
//! Writing statistics back onto the items.
//!
//! This is the step that makes the whole system compound. Analysis that lives only
//! in a report gets read once; analysis written back onto the item is there the
//! next time you consider using it, and the linter can refuse to reuse a question
//! that behaved badly.
//!
//! Calibration accumulates. An item's `calibration` block is not overwritten
//! with the latest administration's numbers; the raw responses from every
//! administration are pooled and the statistics recomputed.
//!
//! Rewording resets it. Every calibration records the fingerprint of the item
//! text it was computed from. Change the stem or an option and the fingerprint
//! changes, the calibration is stale, and the linter says so. Retag the metadata
//! and nothing changes, because the fingerprint covers only what a student saw.
//! Without that rule, pooled statistics quietly become a mixture of two different
//! questions.
//!
//! Nothing is written without being shown. Calibration produces a diff you
//! read before it touches the file. The numbers here decide whether a question gets
//! used again, and a silent automated rewrite of a reviewed bank is not something
//! you want in a course repository.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use crate::assessment::AssessmentFile;
use crate::bank::BankFile;
use crate::catalog::Catalog;
use crate::classical::{self, Analysis, ItemAnalysis, Thresholds};
use crate::date::Date;
use crate::error::{Error, Result};
use crate::irt::{self, Fit};
use crate::item::{Calibration, IrtParams, OptionStat};
use crate::responses::ResponseSet;
use crate::store::Store;
use crate::taxonomy::Flag;
use crate::yaml;
/// What calibration would change about one item.
#[derive(Debug, Clone)]
pub struct Change {
/// The item's global id.
pub uid: String,
/// The bank file it lives in.
pub path: PathBuf,
/// The calibration that would be written.
pub calibration: Calibration,
/// Human-readable before-and-after lines.
pub diff: Vec<String>,
/// Whether the item previously had no calibration at all.
pub is_new: bool,
/// Whether the previous calibration was computed from different item text.
pub was_stale: bool,
}
/// The full plan, across items.
#[derive(Debug, Clone)]
pub struct Plan {
/// One entry per item whose calibration would change.
pub changes: Vec<Change>,
/// Items that were analyzed but could not be matched to a bank item.
pub unmatched: Vec<String>,
/// Cautions worth printing before the diff.
pub warnings: Vec<String>,
/// How many administrations were pooled.
pub administrations: Vec<String>,
}
impl Plan {
/// Whether anything would change.
pub fn is_empty(&self) -> bool {
self.changes.is_empty()
}
/// Renders the plan for a terminal.
///
/// # Returns
///
/// A human-readable diff.
pub fn render(&self) -> String {
let mut out = String::new();
if !self.administrations.is_empty() {
out.push_str(&format!(
"Pooling {} administration(s): {}\n\n",
self.administrations.len(),
self.administrations.join(", ")
));
}
for w in &self.warnings {
out.push_str(&format!("! {w}\n"));
}
if !self.warnings.is_empty() {
out.push('\n');
}
if self.changes.is_empty() {
out.push_str("No calibration changes.\n");
}
for c in &self.changes {
let tag = if c.is_new {
" (new)"
} else if c.was_stale {
" (previous calibration was computed from different text)"
} else {
""
};
out.push_str(&format!("{}{tag}\n", c.uid));
for line in &c.diff {
out.push_str(&format!(" {line}\n"));
}
out.push('\n');
}
if !self.unmatched.is_empty() {
out.push_str(&format!(
"{} analyzed item(s) could not be traced to a bank item and were skipped: {}\n",
self.unmatched.len(),
self.unmatched.join(", ")
));
}
out
}
}
/// Options for building a plan.
#[derive(Debug, Clone)]
pub struct Options {
/// Classical thresholds.
pub thresholds: Thresholds,
/// Whether to fit IRT and record the parameters.
pub irt: bool,
/// IRT settings.
pub irt_options: irt::Options,
/// Whether to include practice assessments. Off by default: practice
/// conditions differ enough that pooling them contaminates the statistics.
pub include_practice: bool,
/// Minimum pooled examinees before writing anything at all.
pub minimum_n: usize,
}
impl Default for Options {
fn default() -> Options {
Options {
thresholds: Thresholds::default(),
irt: true,
irt_options: irt::Options::default(),
include_practice: false,
minimum_n: 10,
}
}
}
/// Builds a calibration plan by pooling every stored administration.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `store` - the response store.
/// * `opts` - calibration options.
///
/// # Returns
///
/// The plan, which changes nothing until [`apply`] is called.
///
/// # Errors
///
/// Returns [`Error::Io`] when the store cannot be read.
pub fn plan(catalog: &Catalog, store: &Store, opts: &Options) -> Result<Plan> {
let records = AssessmentFile::load_all(&catalog.layout.assessments())?;
let by_id: BTreeMap<&str, &AssessmentFile> = records
.iter()
.map(|r| (r.assessment.id.as_str(), r))
.collect();
let stored = store.read_all()?;
let mut warnings = stored.warnings.clone();
// Analysis has to happen per administration, not per item. A corrected
// point-biserial is a correlation against the rest of that test, so it can
// only be computed while the whole administration is in hand. Pooling happens
// afterward, on the resulting numbers.
let mut per_admin: BTreeMap<String, Analysis> = BTreeMap::new();
let mut administrations: BTreeSet<String> = BTreeSet::new();
for admin in stored.administrations() {
let rows: Vec<crate::responses::Response> = stored
.rows
.iter()
.filter(|r| r.administration_id == admin)
.cloned()
.collect();
let Some(first) = rows.first() else { continue };
let record = by_id.get(first.assessment_id.as_str()).copied();
if let Some(rec) = record {
if !opts.include_practice && !rec.assessment.kind.counts_for_calibration() {
continue;
}
}
let mut set = ResponseSet::new();
set.rows = rows;
let analysis = classical::analyze(&set, &opts.thresholds, record, Some(catalog));
administrations.insert(admin.clone());
per_admin.insert(admin, analysis);
}
// Now group the per-item results by the bank item they refer to. The same item
// may have been question 7 one term and question 12 the next.
let mut by_item: BTreeMap<String, Vec<(String, ItemAnalysis)>> = BTreeMap::new();
let mut unmatched: BTreeSet<String> = BTreeSet::new();
for (admin, analysis) in &per_admin {
for item in &analysis.items {
match &item.item_ref {
Some(uid) if catalog.get(uid).is_some() => {
by_item
.entry(uid.clone())
.or_default()
.push((admin.clone(), item.clone()));
}
Some(uid) => {
unmatched.insert(uid.clone());
}
None => {
unmatched.insert(format!("{admin}#{}", item.number));
}
}
}
}
if by_item.is_empty() {
warnings.push(
"no stored responses could be traced to bank items; check that `ingest` was run with \
an assessment record so item references were recorded"
.to_string(),
);
}
// The IRT fit uses whichever administration has the most complete matrix,
// rather than a pooled matrix. Pooling responses across forms into one matrix
// would treat students who never saw an item as having missed it in a way the
// likelihood cannot distinguish from a linked design, and honest linking is a
// bigger problem than this tool should pretend to solve.
let irt_fit = if opts.irt {
best_fit(&stored, opts)
} else {
None
};
if opts.irt && irt_fit.is_none() {
warnings.push(
"IRT was requested but no single administration had enough data to fit; classical \
statistics will still be written"
.to_string(),
);
}
if let Some((admin, fit)) = &irt_fit {
warnings.extend(fit.warnings.clone());
warnings.push(format!(
"IRT parameters come from a single administration ({admin}) rather than from the \
pooled data, because linking across forms is not attempted"
));
}
let mut changes = Vec::new();
for (uid, appearances) in &by_item {
let entry = match catalog.get(uid) {
Some(e) => e,
None => continue,
};
let pooled = pool(appearances);
if pooled.n < opts.minimum_n {
continue;
}
// The IRT parameters come from whichever single administration was fitted,
// matched by the question number this item held there.
let irt_params = match &irt_fit {
Some((fitted_admin, fit)) => appearances
.iter()
.find(|(admin, _)| admin == fitted_admin)
.and_then(|(_, item)| fit.items.iter().find(|i| i.number == item.number))
.map(|i| i.to_params()),
None => None,
};
let calibration = Calibration {
administrations: appearances.iter().map(|(a, _)| a.clone()).collect(),
updated: Some(Date::today()),
fingerprint: Some(entry.item.fingerprint()),
n_examinees: Some(pooled.n),
p_value: Some(round4(pooled.p_value)),
point_biserial: pooled.point_biserial.map(round4),
discrimination_index: pooled.discrimination_index.map(round4),
mean_response_time_seconds: None,
rapid_guess_rate: None,
option_stats: pooled.option_stats.clone(),
irt: irt_params,
flags: pooled.flags.clone(),
};
let previous = entry.item.calibration.as_ref();
let diff = diff_calibration(previous, &calibration);
if diff.is_empty() {
continue;
}
changes.push(Change {
uid: uid.clone(),
path: entry.path.clone(),
calibration,
diff,
is_new: previous.is_none(),
was_stale: previous
.map(|p| p.fingerprint.as_deref() != Some(entry.item.fingerprint().as_str()))
.unwrap_or(false),
});
}
Ok(Plan {
changes,
unmatched: unmatched.into_iter().collect(),
warnings,
administrations: administrations.into_iter().collect(),
})
}
/// Pooled statistics for one item.
struct Pooled {
/// Total examinees across administrations.
n: usize,
/// Examinee-weighted difficulty.
p_value: f64,
/// Examinee-weighted point-biserial.
point_biserial: Option<f64>,
/// Examinee-weighted discrimination index.
discrimination_index: Option<f64>,
/// Pooled per-option statistics.
option_stats: BTreeMap<String, OptionStat>,
/// The union of flags raised in any administration.
flags: Vec<Flag>,
}
/// Pools one item's statistics across the administrations it appeared in.
///
/// Difficulty pools by weighted average, since a proportion correct is comparable
/// across administrations of the same text. Discrimination is also averaged rather
/// than recomputed, and that is the important subtlety: a corrected point-biserial
/// is a correlation against the rest of that test, so the only meaningful pooled
/// value is a weighted average of the within-administration correlations. Merging
/// response matrices from different exams and correlating across the whole thing
/// would produce a number that looks more precise and means less.
///
/// # Arguments
///
/// * `appearances` - the administration id and item analysis for each appearance.
///
/// # Returns
///
/// The pooled statistics.
fn pool(appearances: &[(String, ItemAnalysis)]) -> Pooled {
let mut total_n = 0usize;
let mut p_weighted = 0.0;
let mut rpb_weighted = 0.0;
let mut rpb_weight = 0.0;
let mut d_weighted = 0.0;
let mut d_weight = 0.0;
let mut flags: BTreeSet<Flag> = BTreeSet::new();
// Per-option accumulators, since option letters are stable across forms even
// when the printed order is not.
let mut rate_weighted: BTreeMap<String, f64> = BTreeMap::new();
let mut option_rpb: BTreeMap<String, (f64, f64)> = BTreeMap::new();
let mut upper: BTreeMap<String, (f64, f64)> = BTreeMap::new();
let mut lower: BTreeMap<String, (f64, f64)> = BTreeMap::new();
for (_, item) in appearances {
let n = item.n as f64;
total_n += item.n;
p_weighted += item.p_value * n;
if let Some(r) = item.point_biserial {
rpb_weighted += r * n;
rpb_weight += n;
}
if let Some(d) = item.discrimination_index {
d_weighted += d * n;
d_weight += n;
}
for f in &item.flags {
flags.insert(*f);
}
for (letter, o) in &item.options {
*rate_weighted.entry(letter.clone()).or_insert(0.0) += o.rate * n;
if let Some(r) = o.point_biserial {
let e = option_rpb.entry(letter.clone()).or_insert((0.0, 0.0));
e.0 += r * n;
e.1 += n;
}
if let Some(r) = o.upper_rate {
let e = upper.entry(letter.clone()).or_insert((0.0, 0.0));
e.0 += r * n;
e.1 += n;
}
if let Some(r) = o.lower_rate {
let e = lower.entry(letter.clone()).or_insert((0.0, 0.0));
e.0 += r * n;
e.1 += n;
}
}
}
let denominator = total_n.max(1) as f64;
let average = |m: &BTreeMap<String, (f64, f64)>, letter: &str| -> Option<f64> {
m.get(letter)
.filter(|(_, w)| *w > 0.0)
.map(|(sum, w)| round4(sum / w))
};
let option_stats: BTreeMap<String, OptionStat> = rate_weighted
.keys()
.map(|letter| {
(
letter.clone(),
OptionStat {
selection_rate: Some(round4(rate_weighted[letter] / denominator)),
point_biserial: average(&option_rpb, letter),
upper_group_rate: average(&upper, letter),
lower_group_rate: average(&lower, letter),
},
)
})
.collect();
Pooled {
n: total_n,
p_value: p_weighted / denominator,
point_biserial: if rpb_weight > 0.0 {
Some(rpb_weighted / rpb_weight)
} else {
None
},
discrimination_index: if d_weight > 0.0 {
Some(d_weighted / d_weight)
} else {
None
},
option_stats,
flags: flags.into_iter().collect(),
}
}
/// Picks the administration with the most complete matrix and fits IRT to it.
///
/// # Arguments
///
/// * `stored` - every stored response.
/// * `opts` - calibration options.
///
/// # Returns
///
/// The administration id and its fit, or `None` when none is large enough.
fn best_fit(stored: &ResponseSet, opts: &Options) -> Option<(String, Fit)> {
let mut best: Option<(String, usize, usize)> = None;
for admin in stored.administrations() {
let mut set = ResponseSet::new();
set.rows = stored
.rows
.iter()
.filter(|r| r.administration_id == admin)
.cloned()
.collect();
let m = set.matrix(false);
let cells = m.n_students() * m.n_items();
if m.n_students() < opts.minimum_n || m.n_items() < 5 {
continue;
}
if best.as_ref().map(|(_, c, _)| cells > *c).unwrap_or(true) {
best = Some((admin, cells, m.n_items()));
}
}
let (admin, _, _) = best?;
let mut set = ResponseSet::new();
set.rows = stored
.rows
.iter()
.filter(|r| r.administration_id == admin)
.cloned()
.collect();
let fit = irt::fit(&set.matrix(false), &opts.irt_options);
Some((admin, fit))
}
/// Describes the difference between two calibrations.
///
/// # Arguments
///
/// * `previous` - the existing calibration, if any.
/// * `next` - the computed calibration.
///
/// # Returns
///
/// One line per changed field; empty when nothing meaningful changed.
fn diff_calibration(previous: Option<&Calibration>, next: &Calibration) -> Vec<String> {
let mut out = Vec::new();
let show = |label: &str, before: Option<f64>, after: Option<f64>, out: &mut Vec<String>| match (
before, after,
) {
(Some(b), Some(a)) if (b - a).abs() > 5e-4 => {
out.push(format!("{label}: {b:.3} -> {a:.3}"));
}
(None, Some(a)) => out.push(format!("{label}: (none) -> {a:.3}")),
_ => {}
};
let p = previous;
show("p-value", p.and_then(|c| c.p_value), next.p_value, &mut out);
show(
"point-biserial",
p.and_then(|c| c.point_biserial),
next.point_biserial,
&mut out,
);
show(
"discrimination index",
p.and_then(|c| c.discrimination_index),
next.discrimination_index,
&mut out,
);
let before_n = p.and_then(|c| c.n_examinees).unwrap_or(0);
if let Some(n) = next.n_examinees {
if n != before_n {
out.push(format!("examinees: {before_n} -> {n}"));
}
}
let before_flags: BTreeSet<Flag> = p
.map(|c| c.flags.iter().copied().collect())
.unwrap_or_default();
let after_flags: BTreeSet<Flag> = next.flags.iter().copied().collect();
let added: Vec<&str> = after_flags
.difference(&before_flags)
.map(|f| f.as_str())
.collect();
let removed: Vec<&str> = before_flags
.difference(&after_flags)
.map(|f| f.as_str())
.collect();
if !added.is_empty() {
out.push(format!("flags added: {}", added.join(", ")));
}
if !removed.is_empty() {
out.push(format!("flags cleared: {}", removed.join(", ")));
}
match (p.and_then(|c| c.irt.as_ref()), next.irt.as_ref()) {
(Some(b), Some(a)) if (b.a - a.a).abs() > 5e-3 || (b.b - a.b).abs() > 5e-3 => {
out.push(format!(
"IRT: a {:.2} -> {:.2}, b {:+.2} -> {:+.2}",
b.a, a.a, b.b, a.b
));
}
(None, Some(a)) => out.push(format!("IRT: (none) -> a {:.2}, b {:+.2}", a.a, a.b)),
_ => {}
}
if p.map(|c| c.fingerprint.as_deref()) != Some(next.fingerprint.as_deref()) {
out.push("fingerprint updated to the current item text".to_string());
}
out
}
/// Applies a plan, rewriting the affected bank files.
///
/// Files are rewritten one at a time and each is re-read before editing, so a plan
/// built against a bank that has since changed on disk fails loudly rather than
/// clobbering the newer version.
///
/// # Arguments
///
/// * `plan` - the plan to apply.
///
/// # Returns
///
/// The bank files rewritten.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when an item in the plan is no longer in its bank,
/// and [`Error::Io`] on a write failure.
pub fn apply(plan: &Plan) -> Result<Vec<PathBuf>> {
// Group by file so each is read and written once.
let mut by_file: BTreeMap<&PathBuf, Vec<&Change>> = BTreeMap::new();
for change in &plan.changes {
by_file.entry(&change.path).or_default().push(change);
}
let mut written = Vec::new();
for (path, changes) in by_file {
let mut bank: BankFile = yaml::read(path)?;
for change in changes {
// The uid is `bank::item`; match on the item part.
let item_id = change
.uid
.split_once("::")
.map(|(_, id)| id)
.unwrap_or(&change.uid);
let target = bank.items.iter_mut().find(|i| i.id == item_id);
match target {
Some(item) => item.calibration = Some(change.calibration.clone()),
None => {
return Err(Error::Unresolved {
kind: "item",
id: change.uid.clone(),
context: Some(format!(
"{} — the bank changed since the plan was built; re-run calibration",
path.display()
)),
})
}
}
}
yaml::write(path, &bank)?;
written.push(path.clone());
}
Ok(written)
}
/// Builds a plan for a single administration, from an in-memory analysis.
///
/// Useful right after an exam, before deciding whether to drop a question.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `analysis` - the analysis of that administration.
/// * `fit` - an optional IRT fit.
///
/// # Returns
///
/// The plan.
pub fn plan_from_analysis(
catalog: &Catalog,
record: &AssessmentFile,
analysis: &Analysis,
fit: Option<&Fit>,
) -> Plan {
let admin = crate::responses::administration_id(
&catalog.course.course.code,
record
.assessment
.term
.as_deref()
.unwrap_or(&catalog.course.course.term),
&record.assessment.id,
);
let mut changes = Vec::new();
let mut unmatched = Vec::new();
for item in &analysis.items {
let Some(uid) = item.item_ref.clone() else {
unmatched.push(format!("question {}", item.number));
continue;
};
let Some(entry) = catalog.get(&uid) else {
unmatched.push(uid);
continue;
};
let irt_params: Option<IrtParams> = fit.and_then(|f| {
f.items
.iter()
.find(|i| i.number == item.number)
.map(|i| i.to_params())
});
let calibration = Calibration {
administrations: vec![admin.clone()],
updated: Some(Date::today()),
fingerprint: Some(entry.item.fingerprint()),
n_examinees: Some(item.n),
p_value: Some(round4(item.p_value)),
point_biserial: item.point_biserial.map(round4),
discrimination_index: item.discrimination_index.map(round4),
mean_response_time_seconds: None,
rapid_guess_rate: None,
option_stats: item
.options
.iter()
.map(|(letter, o)| (letter.clone(), o.to_option_stat()))
.collect(),
irt: irt_params,
flags: item.flags.clone(),
};
let previous = entry.item.calibration.as_ref();
let diff = diff_calibration(previous, &calibration);
if diff.is_empty() {
continue;
}
changes.push(Change {
uid,
path: entry.path.clone(),
calibration,
diff,
is_new: previous.is_none(),
was_stale: previous
.map(|p| p.fingerprint.as_deref() != Some(entry.item.fingerprint().as_str()))
.unwrap_or(false),
});
}
Plan {
changes,
unmatched,
warnings: analysis.warnings.clone(),
administrations: vec![admin],
}
}
/// Rounds to four decimals.
fn round4(x: f64) -> f64 {
(x * 1e4).round() / 1e4
}
#[cfg(test)]
mod tests {
use super::*;
use crate::item::IrtModel;
fn calibration(p: f64, rpb: Option<f64>, n: usize, fingerprint: &str) -> Calibration {
Calibration {
administrations: vec!["C/2026S/e1".into()],
updated: None,
fingerprint: Some(fingerprint.to_string()),
n_examinees: Some(n),
p_value: Some(p),
point_biserial: rpb,
discrimination_index: None,
mean_response_time_seconds: None,
rapid_guess_rate: None,
option_stats: BTreeMap::new(),
irt: None,
flags: Vec::new(),
}
}
#[test]
fn a_first_calibration_is_all_new() {
let next = calibration(0.7, Some(0.3), 24, "abc");
let diff = diff_calibration(None, &next);
assert!(diff.iter().any(|d| d.contains("p-value: (none)")));
assert!(diff.iter().any(|d| d.contains("examinees: 0 -> 24")));
}
#[test]
fn identical_calibrations_produce_no_diff() {
let previous = calibration(0.7, Some(0.3), 24, "abc");
let next = calibration(0.7, Some(0.3), 24, "abc");
assert!(diff_calibration(Some(&previous), &next).is_empty());
}
#[test]
fn tiny_changes_are_not_reported() {
let previous = calibration(0.7000, Some(0.30), 24, "abc");
let next = calibration(0.7001, Some(0.30), 24, "abc");
assert!(
diff_calibration(Some(&previous), &next).is_empty(),
"a change below the display precision is noise"
);
}
#[test]
fn a_changed_fingerprint_is_called_out() {
let previous = calibration(0.7, Some(0.3), 24, "old-text");
let next = calibration(0.7, Some(0.3), 24, "new-text");
let diff = diff_calibration(Some(&previous), &next);
assert!(diff.iter().any(|d| d.contains("fingerprint")));
}
#[test]
fn flag_changes_are_reported_in_both_directions() {
let mut previous = calibration(0.7, Some(0.3), 24, "abc");
previous.flags = vec![Flag::TooEasy];
let mut next = calibration(0.7, Some(0.3), 24, "abc");
next.flags = vec![Flag::NegativeDiscrimination];
let diff = diff_calibration(Some(&previous), &next);
assert!(diff
.iter()
.any(|d| d.contains("flags added") && d.contains("negative_discrimination")));
assert!(diff
.iter()
.any(|d| d.contains("flags cleared") && d.contains("too_easy")));
}
#[test]
fn irt_changes_are_reported() {
let previous = calibration(0.7, None, 24, "abc");
let mut next = calibration(0.7, None, 24, "abc");
next.irt = Some(IrtParams {
model: IrtModel::TwoPl,
a: 1.2,
b: -0.4,
c: None,
se_a: None,
se_b: None,
n: Some(24),
bayesian: true,
});
let diff = diff_calibration(Some(&previous), &next);
assert!(diff.iter().any(|d| d.contains("IRT: (none)")));
}
#[test]
fn an_empty_plan_renders_readably() {
let plan = Plan {
changes: Vec::new(),
unmatched: Vec::new(),
warnings: Vec::new(),
administrations: Vec::new(),
};
assert!(plan.is_empty());
assert!(plan.render().contains("No calibration changes"));
}
#[test]
fn a_plan_renders_its_diff_and_warnings() {
let plan = Plan {
changes: vec![Change {
uid: "bank::q-a-001".into(),
path: PathBuf::from("banks/bank.yaml"),
calibration: calibration(0.7, Some(0.3), 24, "abc"),
diff: vec!["p-value: (none) -> 0.700".into()],
is_new: true,
was_stale: false,
}],
unmatched: vec!["question 9".into()],
warnings: vec!["only 24 examinees".into()],
administrations: vec!["C/2026S/e1".into()],
};
let text = plan.render();
assert!(text.contains("bank::q-a-001 (new)"));
assert!(text.contains("p-value"));
assert!(text.contains("only 24 examinees"));
assert!(text.contains("question 9"));
assert!(text.contains("Pooling 1 administration"));
}
}
File diff suppressed because it is too large Load Diff
+1171
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
//! Support for writing items and building assessments from them.
//!
//! The three modules here are what you interact with before an exam exists, and
//! they divide by how much authority each one has.
//!
//! [`lint`] has none. Its 26 rules are advice with stable codes, every one
//! silenceable with `--ignore`. It is deliberately separate from the validation in
//! [`crate::model::bank`], which enforces what must be true and fails. Conflating
//! the two produces a tool that either blocks you on style or lets real errors
//! through.
//!
//! [`select`] draws an assessment from a blueprint. It places objective minimums
//! before level quotas, because a coverage requirement is the constraint most
//! likely to become unsatisfiable, and it prefers least-recently-used items so a
//! bank rotates rather than converging on your favourites.
//!
//! [`jsonschema`] emits JSON Schema so an editor autocompletes the YAML. That is
//! a better authoring experience than any validator: catching `cognitve_process`
//! as you type beats reading it in a list afterward.
pub mod jsonschema;
pub mod lint;
pub mod select;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+717
View File
@@ -0,0 +1,717 @@
//! Drawing an assessment from the item pool.
//!
//! Assembly is a constrained draw, not a random sample, and the constraints are
//! the point. A blueprint says how many items at each level; the pool says which
//! items are eligible; usage history says which ones students have seen recently.
//! What comes out is a form that matches the design you intended rather than
//! whichever questions happened to be at the top of the file.
//!
//! Two ordering rules do most of the work.
//!
//! *Objective minimums come first.* If a blueprint requires two items on
//! `lo-mm-kinetics`, those are placed before the level quotas are filled by
//! anything else, because a level quota can always be filled and a coverage
//! requirement often cannot. Filling in the other order strands the requirement.
//!
//! *Within a level, prefer the least recently used item.* Never-used items go
//! first, then the oldest, then the least often used. This spreads exposure
//! across the bank instead of wearing out your favorite twelve questions, and it
//! is the mechanism that makes writing new items pay off.
//!
//! Every tie is broken by a seeded shuffle, so a draw is reproducible from the
//! seed recorded in the assessment file.
use std::collections::{BTreeMap, BTreeSet};
use crate::assessment::{
Assessment, AssessmentFile, Blueprint, Form, History, Kind, Placement, Platform,
};
use crate::catalog::Catalog;
use crate::course::SCHEMA_VERSION;
use crate::date::Date;
use crate::error::{Error, Result};
use crate::rng::Rng;
use crate::taxonomy::Level;
/// The result of a draw.
#[derive(Debug, Clone)]
pub struct Selection {
/// Scored item ids, grouped and ordered by level.
pub scored: Vec<String>,
/// Bonus item ids.
pub bonus: Vec<String>,
/// Things the caller should know: quotas filled by relaxing a constraint,
/// levels that came up short, cooldowns that had to be ignored.
pub notes: Vec<String>,
}
impl Selection {
/// Every selected id, scored then bonus.
pub fn all(&self) -> Vec<String> {
let mut v = self.scored.clone();
v.extend(self.bonus.clone());
v
}
}
/// Draws an assessment from the pool according to a blueprint.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `blueprint` - the design to satisfy.
/// * `history` - usage history, for the least-recently-used preference.
/// * `as_of` - the date of the assessment, against which cooldowns are measured.
///
/// # Returns
///
/// The selection, together with notes about any constraint that had to bend.
///
/// # Errors
///
/// Returns [`Error::Infeasible`] when a level quota cannot be met even after
/// relaxing the reuse cooldown, with a message saying how many items were
/// available and how many were asked for.
pub fn select(
catalog: &Catalog,
blueprint: &Blueprint,
history: &History,
as_of: Date,
) -> Result<Selection> {
let seed = blueprint.seed.unwrap_or(0);
let mut notes = Vec::new();
// ------------------------------------------------------------------ pool
let eligible: Vec<&crate::catalog::Entry> = catalog
.assemblable()
.into_iter()
.filter(|e| passes_filters(e, blueprint))
.collect();
if eligible.is_empty() {
return Err(Error::Infeasible(
"no approved items match the blueprint's bank, lecture, and topic filters".into(),
));
}
let cooldown = blueprint.cooldown_days.unwrap_or(0);
let mut chosen: Vec<String> = Vec::new();
let mut per_bank: BTreeMap<String, usize> = BTreeMap::new();
// ---------------------------------------------------- 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 {
let mut have = 0;
let mut candidates: Vec<&crate::catalog::Entry> = eligible
.iter()
.copied()
.filter(|e| e.item.learning_objectives.iter().any(|o| o == objective))
.filter(|e| !e.item.bonus)
.collect();
rank(&mut candidates, history, seed, "objective");
for e in candidates {
if have >= *needed {
break;
}
if chosen.contains(&e.uid) {
have += 1;
continue;
}
if !bank_has_room(&per_bank, &e.bank, blueprint) {
continue;
}
if cooldown > 0 && history.in_cooldown(&e.uid, cooldown, as_of) {
continue;
}
chosen.push(e.uid.clone());
*per_bank.entry(e.bank.clone()).or_insert(0) += 1;
have += 1;
}
if have < *needed {
notes.push(format!(
"objective `{objective}` requires {needed} item(s) but only {have} could be \
placed; write more items on it or lower the requirement"
));
}
}
// ------------------------------------------------------- level quotas
let mut scored: Vec<String> = Vec::new();
for (level, want) in &blueprint.level_counts {
if *want == 0 {
continue;
}
let (picked, level_notes) = fill_level(
&eligible,
*level,
*want,
false,
history,
seed,
cooldown,
as_of,
&mut chosen,
&mut per_bank,
blueprint,
)?;
scored.extend(picked);
notes.extend(level_notes);
}
// Items placed to satisfy an objective minimum are scored items too, and
// they must appear exactly once.
for uid in &chosen {
if !scored.contains(uid) {
if let Some(e) = catalog.get(uid) {
if !e.item.bonus {
scored.push(uid.clone());
}
}
}
}
// ------------------------------------------------------------ bonus items
let mut bonus: Vec<String> = Vec::new();
for (level, want) in &blueprint.bonus_counts {
if *want == 0 {
continue;
}
let (picked, level_notes) = fill_level(
&eligible,
*level,
*want,
true,
history,
seed,
cooldown,
as_of,
&mut chosen,
&mut per_bank,
blueprint,
)?;
bonus.extend(picked);
notes.extend(level_notes);
}
// Order the scored items by level so the form ramps in difficulty. Students
// meet the recall items first, which is both kinder and better measurement:
// an early hard item costs time that later easy items cannot recover.
scored.sort_by_key(|uid| {
let e = catalog.get(uid);
(
e.map(|e| e.item.level.code()).unwrap_or(9),
e.map(|e| e.uid.clone()).unwrap_or_default(),
)
});
Ok(Selection {
scored,
bonus,
notes,
})
}
/// Fills one level's quota.
///
/// Cooldowns are relaxed rather than allowed to fail the draw, because an exam
/// that must be given on Thursday is better assembled from a recently used item
/// with a warning than not assembled at all.
///
/// # Arguments
///
/// * `eligible` - the filtered pool.
/// * `level` - the level to fill.
/// * `want` - how many items are needed.
/// * `bonus` - whether to draw bonus items.
/// * `history` - usage history.
/// * `seed` - the tie-breaking seed.
/// * `cooldown` - the reuse cooldown in days, 0 to disable.
/// * `as_of` - the assessment date.
/// * `chosen` - ids already taken, updated in place.
/// * `per_bank` - per-bank counts, updated in place.
/// * `blueprint` - for the per-bank cap.
///
/// # Returns
///
/// The ids picked and any notes.
///
/// # Errors
///
/// Returns [`Error::Infeasible`] when the level cannot be filled at all.
#[allow(clippy::too_many_arguments)]
fn fill_level(
eligible: &[&crate::catalog::Entry],
level: Level,
want: usize,
bonus: bool,
history: &History,
seed: u64,
cooldown: i64,
as_of: Date,
chosen: &mut Vec<String>,
per_bank: &mut BTreeMap<String, usize>,
blueprint: &Blueprint,
) -> Result<(Vec<String>, Vec<String>)> {
let mut notes = Vec::new();
let mut candidates: Vec<&crate::catalog::Entry> = eligible
.iter()
.copied()
.filter(|e| e.item.level == level && e.item.bonus == bonus)
.collect();
let pool_size = candidates.len();
if pool_size < want {
return Err(Error::Infeasible(format!(
"level {} needs {want} {}item(s) but only {pool_size} approved item(s) are \
available; write more or lower the quota",
level.code(),
if bonus { "bonus " } else { "" }
)));
}
rank(
&mut candidates,
history,
seed,
&format!("L{}", level.code()),
);
let mut picked = Vec::new();
let mut skipped_for_cooldown = 0usize;
let mut skipped_for_bank = 0usize;
// Two passes: honor every constraint, then relax the cooldown if short.
for relax in [false, true] {
for e in &candidates {
if picked.len() >= want {
break;
}
if chosen.contains(&e.uid) {
continue;
}
if !bank_has_room(per_bank, &e.bank, blueprint) {
if !relax {
skipped_for_bank += 1;
}
continue;
}
if !relax && cooldown > 0 && history.in_cooldown(&e.uid, cooldown, as_of) {
skipped_for_cooldown += 1;
continue;
}
if relax && cooldown > 0 && history.in_cooldown(&e.uid, cooldown, as_of) {
notes.push(format!(
"level {}: reused `{}` inside the {cooldown}-day cooldown (last used {})",
level.code(),
e.uid,
history
.last_used(&e.uid)
.map(|d| d.to_string())
.unwrap_or_else(|| "unknown".into())
));
}
picked.push(e.uid.clone());
chosen.push(e.uid.clone());
*per_bank.entry(e.bank.clone()).or_insert(0) += 1;
}
if picked.len() >= want {
break;
}
}
if picked.len() < want {
return Err(Error::Infeasible(format!(
"level {} needs {want} item(s); {pool_size} exist but only {} could be placed \
({skipped_for_cooldown} blocked by the reuse cooldown, {skipped_for_bank} by the \
per-bank cap)",
level.code(),
picked.len()
)));
}
Ok((picked, notes))
}
/// Whether an entry passes the blueprint's inclusion filters.
///
/// # Arguments
///
/// * `e` - the entry.
/// * `b` - the blueprint.
///
/// # Returns
///
/// `true` when the item is eligible.
fn passes_filters(e: &crate::catalog::Entry, b: &Blueprint) -> bool {
if !b.banks.is_empty() && !b.banks.contains(&e.bank) {
return false;
}
if !b.lectures.is_empty()
&& !e
.item
.sources
.iter()
.any(|s| b.lectures.contains(&s.lecture))
{
return false;
}
if !b.topics.is_empty() && !e.item.topics.iter().any(|t| b.topics.contains(t)) {
return false;
}
true
}
/// Whether a bank may contribute another item.
///
/// # Arguments
///
/// * `per_bank` - counts so far.
/// * `bank` - the bank in question.
/// * `b` - the blueprint, for the cap.
///
/// # Returns
///
/// `true` when there is room.
fn bank_has_room(per_bank: &BTreeMap<String, usize>, bank: &str, b: &Blueprint) -> bool {
match b.max_per_bank {
Some(cap) => per_bank.get(bank).copied().unwrap_or(0) < cap,
None => true,
}
}
/// Orders candidates least-recently-used first, with a seeded tie-break.
///
/// # Arguments
///
/// * `candidates` - the candidates to order, sorted in place.
/// * `history` - usage history.
/// * `seed` - the tie-breaking seed.
/// * `salt` - distinguishes the shuffles used for different levels, so two
/// levels drawing from overlapping pools do not tie-break identically.
fn rank(candidates: &mut Vec<&crate::catalog::Entry>, history: &History, seed: u64, salt: &str) {
// Shuffle first so the sort's stability turns into a random tie-break.
let mut rng = Rng::from_label(&format!("{seed}/{salt}"));
rng.shuffle(candidates);
candidates.sort_by_key(|e| {
let last = history.last_used(&e.uid);
(
// Never used sorts before ever used.
if last.is_some() { 1 } else { 0 },
last.map(|d| d.days_since_epoch()).unwrap_or(i64::MIN),
history.use_count(&e.uid),
)
});
}
/// Turns a selection into an assessment record ready to write.
///
/// The record captures the key and fingerprint of every item *as of now*, which
/// is what makes later analysis honest about drift.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `selection` - the draw.
/// * `id` - the assessment id.
/// * `title` - the printed title.
/// * `kind` - the kind of assessment.
/// * `date` - the administration date.
/// * `platform` - where it will be administered.
/// * `blueprint` - the blueprint used, recorded for later comparison.
/// * `forms` - how many alternate forms to declare.
///
/// # Returns
///
/// The assessment record.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] if a selected id has vanished from the catalog.
#[allow(clippy::too_many_arguments)]
pub fn to_record(
catalog: &Catalog,
selection: &Selection,
id: &str,
title: &str,
kind: Kind,
date: Date,
platform: Platform,
blueprint: &Blueprint,
forms: usize,
) -> Result<AssessmentFile> {
let default_points = catalog.course.policy.points_per_item;
let mut items = Vec::new();
let mut number = 1u32;
for (uid, is_bonus) in selection
.scored
.iter()
.map(|u| (u, false))
.chain(selection.bonus.iter().map(|u| (u, true)))
{
let e = catalog.require(uid)?;
items.push(Placement {
number,
item: uid.clone(),
version: Some(e.item.version),
fingerprint: Some(e.item.fingerprint()),
points: Some(e.item.points(default_points)),
bonus: is_bonus || e.item.bonus,
key: e.item.key_letters(),
level: Some(e.item.level),
learning_objectives: e.item.learning_objectives.clone(),
credit_overrides: BTreeMap::new(),
dropped: false,
});
number += 1;
}
let form_list: Vec<Form> = (0..forms)
.map(|i| {
let label = form_label(i);
Form {
seed: Rng::from_label(&format!("{id}/form-{label}")).next_u64(),
id: label,
shuffle_items: false,
shuffle_options: true,
}
})
.collect();
Ok(AssessmentFile {
schema_version: SCHEMA_VERSION.to_string(),
assessment: Assessment {
id: id.to_string(),
title: title.to_string(),
term: Some(catalog.course.course.term.clone()),
kind,
date: Some(date),
platform,
minutes_allowed: None,
attempts: None,
shuffle: None,
scoring_policy: None,
instructions: None,
notes: None,
},
blueprint: Some(blueprint.clone()),
forms: form_list,
items,
})
}
/// The label for the nth form: A, B, ... Z, AA, AB, ...
///
/// # Arguments
///
/// * `i` - the zero-based form index.
///
/// # Returns
///
/// The label.
fn form_label(i: usize) -> String {
let mut n = i;
let mut out = String::new();
loop {
out.insert(0, (b'A' + (n % 26) as u8) as char);
if n < 26 {
break;
}
n = n / 26 - 1;
}
out
}
/// The order items appear in on one form.
///
/// Permuting a form is a display concern, so it is computed on demand from the
/// recorded seed rather than stored. That keeps the record small and guarantees
/// every export of form B agrees.
///
/// # Arguments
///
/// * `record` - the assessment record.
/// * `form` - the form to lay out.
///
/// # Returns
///
/// Placements in printed order for this form. The `number` field is left as
/// recorded, since it is the join key to grading data and must not change
/// between forms; use the position in the returned vector for what to print.
pub fn layout(record: &AssessmentFile, form: &Form) -> Vec<Placement> {
// Bonus items always come last, whatever the shuffle says: they are outside
// the scored total, and burying one mid-form invites students to spend time
// there that the graded questions needed.
let mut scored: Vec<Placement> = record.items.iter().filter(|p| !p.bonus).cloned().collect();
let bonus: Vec<Placement> = record.items.iter().filter(|p| p.bonus).cloned().collect();
if form.shuffle_items {
let mut rng = Rng::new(form.seed);
rng.shuffle(&mut scored);
}
scored.into_iter().chain(bonus.into_iter()).collect()
}
/// The option order for one item on one form.
///
/// # Arguments
///
/// * `form` - the form.
/// * `uid` - the item's global id, which salts the permutation so two items on
/// the same form do not permute identically.
/// * `n` - the number of options.
///
/// # Returns
///
/// A permutation of `0..n`.
pub fn option_order(form: &Form, uid: &str, n: usize) -> Vec<usize> {
let mut order: Vec<usize> = (0..n).collect();
if form.shuffle_options && n > 1 {
let mut rng = Rng::from_label(&format!("{}/{}/{}", form.seed, form.id, uid));
rng.shuffle(&mut order);
}
order
}
/// Compares a record against its blueprint.
///
/// # Arguments
///
/// * `record` - the assessment record.
///
/// # Returns
///
/// One message per discrepancy, empty when the form matches the design.
pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
let Some(bp) = &record.blueprint else {
return vec!["the record carries no blueprint to check against".into()];
};
let actual = record.level_counts();
let mut out = Vec::new();
for level in Level::ALL {
let want = bp.level_counts.get(&level).copied().unwrap_or(0);
let got = actual.get(&level).copied().unwrap_or(0);
if want != got {
out.push(format!(
"level {}: blueprint asks for {want}, the form has {got}",
level.code()
));
}
}
for (objective, needed) in &bp.objective_minimums {
let got = record
.items
.iter()
.filter(|p| p.learning_objectives.iter().any(|o| o == objective))
.count();
if got < *needed {
out.push(format!(
"objective `{objective}`: blueprint asks for {needed} item(s), the form has {got}"
));
}
}
out
}
/// The set of objectives an assessment covers.
///
/// # Arguments
///
/// * `record` - the assessment record.
///
/// # Returns
///
/// The objective ids, deduplicated.
pub fn covered_objectives(record: &AssessmentFile) -> BTreeSet<String> {
record
.items
.iter()
.flat_map(|p| p.learning_objectives.iter().cloned())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn form_labels_extend_past_z() {
assert_eq!(form_label(0), "A");
assert_eq!(form_label(1), "B");
assert_eq!(form_label(25), "Z");
assert_eq!(form_label(26), "AA");
assert_eq!(form_label(27), "AB");
}
#[test]
fn option_order_is_a_reproducible_permutation() {
let form = Form {
id: "A".into(),
seed: 12345,
shuffle_items: false,
shuffle_options: true,
};
let a = option_order(&form, "b::q-1", 5);
let b = option_order(&form, "b::q-1", 5);
assert_eq!(a, b, "same inputs give the same order");
let other = option_order(&form, "b::q-2", 5);
assert_ne!(a, other, "different items permute differently");
let mut sorted = a.clone();
sorted.sort_unstable();
assert_eq!(sorted, vec![0, 1, 2, 3, 4]);
}
#[test]
fn option_order_is_identity_when_shuffling_is_off() {
let form = Form {
id: "A".into(),
seed: 1,
shuffle_items: false,
shuffle_options: false,
};
assert_eq!(option_order(&form, "b::q-1", 4), vec![0, 1, 2, 3]);
}
#[test]
fn blueprint_check_reports_shortfalls() {
let record: AssessmentFile = serde_yaml_ng::from_str(
r#"
assessment: { id: x, title: X }
blueprint:
level_counts: { 1: 2, 3: 1 }
objective_minimums: { lo-key: 2 }
items:
- { number: 1, item: "b::q-1", level: 1, learning_objectives: [lo-key] }
- { number: 2, item: "b::q-2", level: 1 }
"#,
)
.unwrap();
let issues = check_blueprint(&record);
assert!(issues.iter().any(|i| i.contains("level 3")), "{issues:?}");
assert!(issues.iter().any(|i| i.contains("lo-key")), "{issues:?}");
// Level 1 matches, so it must not be reported.
assert!(!issues.iter().any(|i| i.contains("level 1")));
}
#[test]
fn covered_objectives_deduplicates() {
let record: AssessmentFile = serde_yaml_ng::from_str(
r#"
assessment: { id: x, title: X }
items:
- { number: 1, item: "b::q-1", learning_objectives: [lo-a, lo-b] }
- { number: 2, item: "b::q-2", learning_objectives: [lo-a] }
"#,
)
.unwrap();
let set = covered_objectives(&record);
assert_eq!(set.len(), 2);
assert!(set.contains("lo-a"));
}
}
+29
View File
@@ -0,0 +1,29 @@
//! Getting grading data in, and keeping it.
//!
//! Every platform exports a different shape and none of them is analyzable, so
//! this module's job is to turn all of them into one long-format row per student
//! per item — see [`responses::Response`].
//!
//! ```text
//! gradescope ──┐
//! ├──▶ responses (canonical long form) ──▶ store ──▶ data/*.parquet
//! canvas ──────┘
//! ```
//!
//! [`gradescope`] and [`canvas`] are both written against real exports rather than
//! documentation, because the formats do things the documentation does not mention:
//! bonus questions carrying a maximum of zero, trailing rows shorter than the
//! header, rubric labels with the instructor's parenthetical attached, and answer
//! *text* where you wanted an option letter.
//!
//! [`store`] keeps one file per administration rather than one big table, so
//! re-ingesting one exam cannot corrupt another. [`store_parquet`] contains every
//! use of `arrow` and `parquet` in the entire crate, which is what makes
//! `--no-default-features` a one-file change rather than a refactor.
pub mod canvas;
pub mod gradescope;
pub mod responses;
pub mod store;
#[cfg(feature = "parquet")]
pub mod store_parquet;
+619
View File
@@ -0,0 +1,619 @@
//! Reading Canvas's "Student Analysis" quiz export.
//!
//! Canvas exports one wide CSV for the whole quiz. After the student columns come
//! two columns per question: one headed `<question id>: <question text>` holding
//! the answer text the student chose, and one immediately after it holding the
//! points earned, headed with the points possible.
//!
//! The awkward part is that Canvas records answer text, not option letters. So
//! recovering "this student chose C" means matching the exported text back against
//! the item's options. That matching is done on normalized text because the text
//! makes a round trip through the QTI export and Canvas's own HTML sanitizer, and
//! comes back with different markup than it left with.
//!
//! When a match cannot be made, the row keeps its score and loses its option
//! letter, with a warning naming the question. Totals and per-objective mastery
//! still work, and only distractor analysis is affected.
//!
//! Questions are aligned to item numbers by matching stem text the same way, with
//! column order as the fallback. Order alone would be wrong for any quiz where
//! Canvas shuffled the questions.
use std::collections::BTreeMap;
use std::path::Path;
use crate::assessment::AssessmentFile;
use crate::catalog::Catalog;
use crate::date::Date;
use crate::error::{Error, Result};
use crate::responses::{administration_id, Response, ResponseSet};
/// What the ingest needs that the export does not carry.
pub type Context = crate::gradescope::Context;
/// One question column pair found in the header.
#[derive(Debug, Clone)]
struct QuestionColumn {
/// Index of the answer-text column.
text_index: usize,
/// Index of the points-earned column, when the header had one.
points_index: Option<usize>,
/// The Canvas question id, from the column header.
///
/// Kept for diagnostics rather than for matching: when a column cannot be
/// traced to the assessment record, or an answer cannot be traced to an option,
/// this id is what lets you find the question in Canvas and see what differs.
/// Matching itself goes by question text, because Canvas ids change when a quiz
/// is copied to a new term.
canvas_id: String,
/// The question text from the header.
question_text: String,
/// Points possible, parsed from the points column's header.
points_possible: f64,
/// The item number this maps to, once resolved.
item_number: Option<u32>,
}
/// Splits a question column header into its id and text.
///
/// # Arguments
///
/// * `header` - the column header.
///
/// # Returns
///
/// The id and text, or `None` when the header is not a question column.
fn split_question_header(header: &str) -> Option<(String, String)> {
let (id, rest) = header.split_once(':')?;
let id = id.trim();
if id.is_empty() || !id.chars().all(|c| c.is_ascii_digit()) {
return None;
}
Some((id.to_string(), rest.trim().to_string()))
}
/// Normalizes text for comparison.
///
/// Strips HTML tags, decodes the handful of entities that survive a QTI round
/// trip, drops punctuation, lowercases, and collapses whitespace.
///
/// # Arguments
///
/// * `s` - the text.
///
/// # Returns
///
/// The normalized form.
pub fn normalize(s: &str) -> String {
// Strip tags.
let mut stripped = String::with_capacity(s.len());
let mut in_tag = false;
for ch in s.chars() {
match ch {
'<' => in_tag = true,
'>' => {
in_tag = false;
stripped.push(' ');
}
_ if in_tag => {}
_ => stripped.push(ch),
}
}
// Decode the entities that actually appear.
let decoded = stripped
.replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&#39;", "'")
.replace("&rarr;", "->")
.replace("&harr;", "<->")
.replace("&minus;", "-");
let mut out = String::with_capacity(decoded.len());
let mut last_space = true;
for ch in decoded.chars() {
let c = ch.to_ascii_lowercase();
if c.is_alphanumeric() {
out.push(c);
last_space = false;
} else if c.is_whitespace() || c == '-' || c == '_' {
if !last_space {
out.push(' ');
last_space = true;
}
}
// Everything else — punctuation, entity leftovers — is dropped.
}
out.trim().to_string()
}
/// Reads a Canvas Student Analysis export.
///
/// # Arguments
///
/// * `path` - the CSV file.
/// * `ctx` - identifying information.
/// * `record` - the assessment record, used to align questions to item numbers.
/// * `catalog` - the loaded course, used to recover option letters from answer
/// text. Without it, scores are still ingested but letters are not.
///
/// # Returns
///
/// The normalized responses.
///
/// # Errors
///
/// Returns [`Error::Csv`] on a malformed file and [`Error::Invalid`] when no
/// question columns can be found.
pub fn ingest(
path: &Path,
ctx: &Context,
record: Option<&AssessmentFile>,
catalog: Option<&Catalog>,
) -> Result<ResponseSet> {
let mut reader = csv::ReaderBuilder::new()
.flexible(true)
.has_headers(true)
.from_path(path)
.map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
let header = reader
.headers()
.map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?
.clone();
let header: Vec<String> = header.iter().map(|h| h.trim().to_string()).collect();
let find = |name: &str| header.iter().position(|h| h.eq_ignore_ascii_case(name));
let name_col = find("name");
let id_col = find("id");
let sis_col = find("sis_id").or_else(|| find("sis id"));
let section_col = find("section");
let submitted_col = find("submitted");
let attempt_col = find("attempt");
// Locate the question column pairs.
let mut questions: Vec<QuestionColumn> = Vec::new();
for (i, h) in header.iter().enumerate() {
if let Some((canvas_id, question_text)) = split_question_header(h) {
// The next column holds points earned; its header is the points
// possible. Canvas writes it as a bare number.
let (points_index, points_possible) = match header.get(i + 1) {
Some(next) => match next.trim().parse::<f64>() {
Ok(p) => (Some(i + 1), p),
Err(_) => (None, 0.0),
},
None => (None, 0.0),
};
questions.push(QuestionColumn {
text_index: i,
points_index,
canvas_id,
question_text,
points_possible,
item_number: None,
});
}
}
if questions.is_empty() {
return Err(Error::Invalid(vec![format!(
"{} has no question columns; a Canvas Student Analysis export heads each question \
`<id>: <question text>`. Did you export the Item Analysis instead?",
path.display()
)]));
}
let mut warnings = Vec::new();
align_questions(&mut questions, record, catalog, &mut warnings);
// Build the answer-text lookup once per question: normalized option text to
// option letter.
let mut answer_lookup: BTreeMap<usize, BTreeMap<String, String>> = BTreeMap::new();
let mut item_meta: BTreeMap<usize, (Option<String>, Vec<String>)> = BTreeMap::new();
if let (Some(cat), Some(rec)) = (catalog, record) {
for (qi, q) in questions.iter().enumerate() {
let Some(number) = q.item_number else {
continue;
};
let Some(placement) = rec.placement(number) else {
continue;
};
let Some(entry) = cat.get(&placement.item) else {
continue;
};
let mut map = BTreeMap::new();
for opt in &entry.item.options {
map.insert(normalize(&opt.text), opt.id.clone());
}
answer_lookup.insert(qi, map);
item_meta.insert(qi, (Some(placement.item.clone()), placement.key.clone()));
}
}
let admin = administration_id(&ctx.course, &ctx.term, &ctx.assessment_id);
let mut set = ResponseSet::new();
let mut unmatched_answers: BTreeMap<(u32, String), usize> = BTreeMap::new();
for rec in reader.records() {
let rec = rec.map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
let cell = |i: Option<usize>| -> Option<String> {
i.and_then(|i| rec.get(i))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
};
let sid = cell(sis_col).or_else(|| cell(id_col));
let name = cell(name_col);
if sid.is_none() && name.is_none() {
continue;
}
// Canvas emits a "Test Student" row for anyone who previewed the quiz.
if name.as_deref() == Some("Test Student") {
continue;
}
let student_key = sid
.clone()
.or_else(|| name.clone())
.unwrap_or_else(|| "unknown".to_string());
let section = cell(section_col);
let submitted = cell(submitted_col);
let attempt = cell(attempt_col).and_then(|a| a.parse::<u32>().ok());
// Only the graded attempt is exported per row, but a student who never
// submitted still gets a row; those carry no responses.
if submitted.is_none() && attempt.is_none() {
continue;
}
for (qi, q) in questions.iter().enumerate() {
let Some(number) = q.item_number else {
continue;
};
let answer_text = rec.get(q.text_index).map(|s| s.trim()).unwrap_or("");
let score = q
.points_index
.and_then(|i| rec.get(i))
.and_then(|s| s.trim().parse::<f64>().ok())
.unwrap_or(0.0);
let points = q.points_possible;
let credit = if points > 0.0 { score / points } else { 0.0 };
let mut selected = Vec::new();
if !answer_text.is_empty() {
let normalized = normalize(answer_text);
match answer_lookup.get(&qi).and_then(|m| m.get(&normalized)) {
Some(letter) => selected.push(letter.clone()),
None => {
*unmatched_answers
.entry((number, q.canvas_id.clone()))
.or_insert(0) += 1;
}
}
}
let (item_ref, key) = item_meta.get(&qi).cloned().unwrap_or((None, Vec::new()));
let correct = if answer_text.is_empty() {
None
} else if points > 0.0 {
Some(credit >= 0.999)
} else if !key.is_empty() && !selected.is_empty() {
Some(selected == key)
} else {
None
};
set.rows.push(Response {
administration_id: admin.clone(),
course: ctx.course.clone(),
term: ctx.term.clone(),
assessment_id: ctx.assessment_id.clone(),
date: ctx.date,
form: ctx.form.clone(),
student_key: student_key.clone(),
sid: sid.clone(),
name: name.clone(),
email: None,
section: section.clone(),
item_number: number,
item_ref,
item_version: None,
selected,
eliminated: Vec::new(),
correct,
credit,
points_possible: points,
score,
response_time_seconds: None,
level: None,
learning_objectives: Vec::new(),
topics: Vec::new(),
bonus: false,
dropped: false,
});
}
}
for ((number, canvas_id), count) in unmatched_answers {
warnings.push(format!(
"question {number} (Canvas id {canvas_id}): {count} answer(s) did not match any option \
text, so those responses have a score but no option letter; distractor analysis for \
this item will be incomplete. The usual cause is the option text being edited in \
Canvas after import"
));
}
if set.rows.is_empty() {
warnings.push(
"no student rows were found; the export may contain only the header, or every row \
may be an unsubmitted attempt"
.to_string(),
);
}
set.warnings.extend(warnings);
Ok(set)
}
/// Assigns an item number to each question column.
///
/// Matching on stem text is preferred over column order because Canvas shuffles
/// questions when the quiz says to, and the export follows the shuffled order.
///
/// # Arguments
///
/// * `questions` - the columns, updated in place.
/// * `record` - the assessment record.
/// * `catalog` - the loaded course.
/// * `warnings` - collects anything ambiguous.
fn align_questions(
questions: &mut [QuestionColumn],
record: Option<&AssessmentFile>,
catalog: Option<&Catalog>,
warnings: &mut Vec<String>,
) {
let Some(rec) = record else {
// Without a record, the only sensible assumption is column order.
for (i, q) in questions.iter_mut().enumerate() {
q.item_number = Some(i as u32 + 1);
}
warnings.push(
"no assessment record was supplied, so questions were matched to item numbers by \
column order; pass --assessment to match on question text instead"
.to_string(),
);
return;
};
// Normalized stem to item number, when the catalog is available.
let mut by_stem: BTreeMap<String, Vec<u32>> = BTreeMap::new();
if let Some(cat) = catalog {
for p in &rec.items {
if let Some(entry) = cat.get(&p.item) {
by_stem
.entry(normalize(&entry.item.stem))
.or_default()
.push(p.number);
}
}
}
let mut used: Vec<u32> = Vec::new();
let mut unmatched: Vec<String> = Vec::new();
for (i, q) in questions.iter_mut().enumerate() {
let normalized = normalize(&q.question_text);
let candidates = by_stem.get(&normalized);
match candidates {
Some(numbers) => {
// Prefer a number not already claimed, so two items sharing a stem
// do not collapse onto one.
match numbers.iter().find(|n| !used.contains(n)) {
Some(n) => {
q.item_number = Some(*n);
used.push(*n);
}
None => {
q.item_number = Some(numbers[0]);
}
}
}
None => {
// Fall back to position, which is right for an unshuffled quiz.
let fallback = rec.items.get(i).map(|p| p.number).unwrap_or(i as u32 + 1);
if !used.contains(&fallback) {
used.push(fallback);
}
q.item_number = Some(fallback);
// Report the Canvas question id, not just a count. The id is what
// you can search for in Canvas to see which question this was, and
// an unmatched column usually means the text was edited there after
// import — so being able to find it is the whole remedy.
unmatched.push(format!("{} (assumed question {fallback})", q.canvas_id));
}
}
}
if !unmatched.is_empty() {
warnings.push(format!(
"{} of {} question column(s) could not be matched to the assessment record by question \
text and were matched by column order instead. Canvas question id(s): {}. Check that \
the record describes this quiz, and that the question text was not edited in Canvas \
after import",
unmatched.len(),
questions.len(),
unmatched.join(", ")
));
}
}
/// Guesses a date from a Canvas timestamp column.
///
/// # Arguments
///
/// * `s` - the timestamp, e.g. `2026-04-01 14:03:22 UTC`.
///
/// # Returns
///
/// The date, when the leading token parses as one.
pub fn date_from_timestamp(s: &str) -> Option<Date> {
s.split_whitespace().next()?.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognizes_question_headers() {
assert_eq!(
split_question_header("123456: Which enzyme catalyzes the step?"),
Some((
"123456".to_string(),
"Which enzyme catalyzes the step?".to_string()
))
);
assert_eq!(split_question_header("name"), None);
assert_eq!(split_question_header("n correct"), None);
// A colon in prose is not a question column.
assert_eq!(split_question_header("Note: read carefully"), None);
}
#[test]
fn normalization_survives_a_qti_round_trip() {
// Option text authored as `K#sub[m] increases` is exported as HTML and
// comes back from Canvas wrapped in tags. Both must normalize to the same
// thing as the HTML we sent, or letters cannot be recovered.
let sent = "K<sub>m</sub> increases";
let returned = "<p>K<sub>m</sub> increases</p>";
assert_eq!(normalize(sent), normalize(returned));
assert_eq!(normalize(returned), "k m increases");
assert_eq!(normalize("K&nbsp;m increases"), "k m increases");
}
#[test]
fn normalization_ignores_punctuation_and_case() {
assert_eq!(
normalize("The Rate, Increases!"),
normalize("the rate increases")
);
assert_eq!(normalize(" spaced out "), "spaced out");
assert_eq!(normalize("<b>bold</b> text"), "bold text");
}
#[test]
fn timestamps_yield_dates() {
assert_eq!(
date_from_timestamp("2026-04-01 14:03:22 UTC").map(|d| d.to_string()),
Some("2026-04-01".to_string())
);
assert_eq!(date_from_timestamp("not a date"), None);
}
#[test]
fn falls_back_to_column_order_without_a_record() {
let mut questions = vec![
QuestionColumn {
text_index: 8,
points_index: Some(9),
canvas_id: "1".into(),
question_text: "Q one".into(),
points_possible: 1.0,
item_number: None,
},
QuestionColumn {
text_index: 10,
points_index: Some(11),
canvas_id: "2".into(),
question_text: "Q two".into(),
points_possible: 1.0,
item_number: None,
},
];
let mut warnings = Vec::new();
align_questions(&mut questions, None, None, &mut warnings);
assert_eq!(questions[0].item_number, Some(1));
assert_eq!(questions[1].item_number, Some(2));
assert_eq!(warnings.len(), 1);
}
#[test]
fn parses_a_realistic_export() {
let dir = std::env::temp_dir().join(format!("cb-canvas-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("analysis.csv");
std::fs::write(
&path,
"name,id,sis_id,section,submitted,attempt,\
1001: Which enzyme?,1.0,1002: Which pathway?,1.0,n correct,n incorrect,score\n\
Ada Lovelace,9001,1234567,L01,2026-04-01 14:00:00 UTC,1,Hexokinase,1.0,Glycolysis,\
1.0,2,0,2.0\n\
Alan Turing,9002,7654321,L01,2026-04-01 14:05:00 UTC,1,Pyruvate kinase,0.0,\
Glycolysis,1.0,1,1,1.0\n\
Test Student,9999,,L01,2026-04-01 13:00:00 UTC,1,Hexokinase,1.0,Glycolysis,1.0,2,0,\
2.0\n",
)
.unwrap();
let ctx = Context {
course: "BIOSC1540".into(),
term: "2026S".into(),
assessment_id: "quiz-1".into(),
date: None,
form: None,
};
let set = ingest(&path, &ctx, None, None).unwrap();
// Two real students, two questions each. The preview row is discarded.
assert_eq!(set.rows.len(), 4, "{:?}", set.warnings);
assert!(set
.rows
.iter()
.all(|r| r.name.as_deref() != Some("Test Student")));
assert_eq!(set.scored_total("1234567"), 2.0);
assert_eq!(set.scored_total("7654321"), 1.0);
let alan_q1 = set
.rows
.iter()
.find(|r| r.student_key == "7654321" && r.item_number == 1)
.unwrap();
assert_eq!(alan_q1.correct, Some(false));
assert_eq!(alan_q1.credit, 0.0);
// No catalog was supplied, so no letter could be recovered.
assert!(alan_q1.selected.is_empty());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn rejects_the_wrong_export_kind() {
let dir = std::env::temp_dir().join(format!("cb-canvas-bad-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("item.csv");
std::fs::write(&path, "question,discrimination index\nQ1,0.4\n").unwrap();
let ctx = Context {
course: "C".into(),
term: "T".into(),
assessment_id: "a".into(),
date: None,
form: None,
};
let err = ingest(&path, &ctx, None, None).unwrap_err();
assert!(err.to_string().contains("Student Analysis"));
std::fs::remove_dir_all(&dir).ok();
}
}
+855
View File
@@ -0,0 +1,855 @@
//! Reading Gradescope's per-question CSV exports.
//!
//! Gradescope exports one file per question, named `1.csv` through `N.csv`, in
//! wide form. Every assumption encoded here was checked against a real 32-question,
//! 24-student export rather than inferred from documentation, because several of
//! them are not what the format suggests.
//!
//! The layout is:
//!
//! ```text
//! Assignment Submission ID, Question Submission ID, First Name, Last Name,
//! SID, Email, Sections, Score, Submission Time, <rubric columns...>,
//! Adjustment, Comments, Grader, Tags
//! ```
//!
//! The rubric columns are the header slice strictly between `Submission Time`
//! and `Adjustment`. Student cells in those columns are the literal strings
//! `true` and `false`.
//!
//! Four things about real exports that a naive parser gets wrong:
//!
//! *Trailing rows are not aligned with the header.* After the student rows come
//! `Point Values`, `Rubric Numbers`, and `Scoring Method` rows with a label, some
//! empty cells, and then the numbers. The CSV reader must be in flexible mode or
//! it errors on the whole file.
//!
//! *Rubric labels are not always bare option letters.* Real ones include
//! `Selected C`, `Eliminated A`, and — this is the interesting case — a letter
//! followed by an instructor's parenthetical, such as
//! `B (Technically speaking, this answer describes HBD/HBA, but I can see how
//! this distractor is poorly written.)`. Those columns carry partial credit.
//!
//! *Partial credit awarded to a distractor is evidence, not noise.* When you gave
//! 1.0 of 1.5 points for choosing B, you decided at grading time that B was
//! partly defensible. That is exactly the ambiguity signal item analysis is
//! trying to detect, so it is captured as [`Flag::Ambiguous`] rather than
//! rounded away.
//!
//! *Bonus questions have `max_points` of zero* while a rubric column still
//! carries points. Key detection therefore uses the largest value across the
//! maximum *and* every column, not the maximum alone.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use crate::date::Date;
use crate::error::{Error, Result};
use crate::responses::{administration_id, Response, ResponseSet};
use crate::taxonomy::Flag;
/// What a rubric column represents.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColumnKind {
/// The student selected this option.
Select,
/// The student eliminated this option, under elimination scoring.
Eliminate,
/// Something else: a catch-all rubric row such as
/// `Incorrect selection with no eliminations`.
Other,
}
/// One rubric column.
#[derive(Debug, Clone)]
pub struct RubricColumn {
/// The header label, verbatim.
pub label: String,
/// What the column means.
pub kind: ColumnKind,
/// The option letter, when the label named one.
pub letter: Option<String>,
/// The instructor's parenthetical annotation, when there was one. Worth
/// keeping: it is usually the instructor explaining why an item is flawed.
pub note: Option<String>,
/// Points this column awards, from the `Point Values` row.
pub value: Option<f64>,
}
/// One student's row in a question file.
#[derive(Debug, Clone)]
pub struct StudentRow {
/// The institutional student id.
pub sid: Option<String>,
/// First and last name joined.
pub name: Option<String>,
/// The email.
pub email: Option<String>,
/// Section or lab.
pub section: Option<String>,
/// Points awarded, authoritative.
pub score: f64,
/// The submission timestamp, verbatim.
pub submission_time: Option<String>,
/// Indices of rubric columns marked `true`.
pub marks: Vec<usize>,
}
/// One parsed question file.
#[derive(Debug, Clone)]
pub struct Question {
/// The question number, from the file name.
pub number: u32,
/// Where it came from.
pub path: PathBuf,
/// The rubric columns, in header order.
pub columns: Vec<RubricColumn>,
/// The maximum points from the `Point Values` row.
pub max_points: f64,
/// The scoring method, when stated.
pub scoring_method: Option<String>,
/// The student rows.
pub rows: Vec<StudentRow>,
}
impl Question {
/// The largest point value anywhere in the question.
///
/// Bonus questions record a maximum of zero while still awarding points, so
/// the key has to be found by looking at the columns too.
///
/// # Returns
///
/// The largest value seen.
pub fn top_value(&self) -> f64 {
let mut top = self.max_points;
for c in &self.columns {
if let Some(v) = c.value {
if v > top {
top = v;
}
}
}
top
}
/// The option letters that earn full credit.
///
/// # Returns
///
/// The keyed letters, sorted. Empty when the point values were absent.
pub fn keyed(&self) -> Vec<String> {
let top = self.top_value();
if top <= 0.0 {
return Vec::new();
}
let mut out: BTreeSet<String> = BTreeSet::new();
for c in &self.columns {
if c.kind == ColumnKind::Select {
if let (Some(letter), Some(v)) = (&c.letter, c.value) {
if (v - top).abs() < 1e-9 {
out.insert(letter.clone());
}
}
}
}
out.into_iter().collect()
}
/// Distractors that were awarded partial credit at grading time.
///
/// # Returns
///
/// Letter, points awarded, and the instructor's note if any.
pub fn partial_credit(&self) -> Vec<(String, f64, Option<String>)> {
let top = self.top_value();
let mut out = Vec::new();
for c in &self.columns {
if c.kind != ColumnKind::Select {
continue;
}
if let (Some(letter), Some(v)) = (&c.letter, c.value) {
if v > 0.0 && v < top - 1e-9 {
out.push((letter.clone(), v, c.note.clone()));
}
}
}
out
}
/// Whether this looks like a bonus question.
///
/// # Returns
///
/// `true` when the maximum is zero but a column still awards points.
pub fn looks_like_bonus(&self) -> bool {
self.max_points <= 0.0 && self.top_value() > 0.0
}
/// Whether elimination scoring was in use.
pub fn uses_elimination(&self) -> bool {
self.columns.iter().any(|c| c.kind == ColumnKind::Eliminate)
}
/// Points the question was worth.
///
/// # Returns
///
/// The stated maximum, falling back to the largest column value for bonus
/// questions so that credit is still a meaningful fraction.
pub fn points_possible(&self) -> f64 {
if self.max_points > 0.0 {
self.max_points
} else {
self.top_value()
}
}
/// Flags implied by how the question was graded.
///
/// # Returns
///
/// [`Flag::Ambiguous`] when a distractor received partial credit.
pub fn implied_flags(&self) -> Vec<Flag> {
if self.partial_credit().is_empty() {
Vec::new()
} else {
vec![Flag::Ambiguous]
}
}
}
/// What the ingest needs to know that the export does not say.
#[derive(Debug, Clone)]
pub struct Context {
/// The course code.
pub course: String,
/// The term.
pub term: String,
/// The assessment id.
pub assessment_id: String,
/// The administration date.
pub date: Option<Date>,
/// The form, when forms were used.
pub form: Option<String>,
}
/// The result of reading a directory of question files.
#[derive(Debug, Clone)]
pub struct Import {
/// The normalized responses.
pub responses: ResponseSet,
/// The parsed question files, kept so grading-time decisions can be folded
/// into item calibration.
pub questions: Vec<Question>,
}
/// Classifies a rubric column label.
///
/// # Arguments
///
/// * `label` - the header text.
///
/// # Returns
///
/// The kind, the option letter if the label named one, and any trailing
/// annotation with its surrounding punctuation trimmed.
pub fn classify(label: &str) -> (ColumnKind, Option<String>, Option<String>) {
let trimmed = label.trim();
let lower = trimmed.to_ascii_lowercase();
let (kind, rest) = if let Some(r) = lower.strip_prefix("selected ") {
(
ColumnKind::Select,
trimmed[trimmed.len() - r.len()..].trim(),
)
} else if let Some(r) = lower.strip_prefix("eliminated ") {
(
ColumnKind::Eliminate,
trimmed[trimmed.len() - r.len()..].trim(),
)
} else {
(ColumnKind::Select, trimmed)
};
let mut chars = rest.chars();
let Some(first) = chars.next() else {
return (ColumnKind::Other, None, Some(label.to_string()));
};
let upper = first.to_ascii_uppercase();
let tail = chars.as_str();
// A single letter A-H, optionally followed by an annotation that does not
// begin with another alphanumeric. `B (poorly written)` is option B;
// `Both A and C are wrong` is not.
let letter_like = upper.is_ascii_uppercase()
&& ('A'..='H').contains(&upper)
&& tail
.chars()
.next()
.map(|c| !c.is_alphanumeric())
.unwrap_or(true);
if !letter_like {
return (ColumnKind::Other, None, Some(label.to_string()));
}
let note = tail.trim().trim_matches(|c: char| "().,;: ".contains(c));
let note = if note.is_empty() {
None
} else {
Some(note.to_string())
};
(kind, Some(upper.to_string()), note)
}
/// Parses one question file.
///
/// # Arguments
///
/// * `path` - the CSV file.
///
/// # Returns
///
/// The parsed question.
///
/// # Errors
///
/// Returns [`Error::Csv`] on a malformed file and [`Error::Invalid`] when the
/// header lacks the `Submission Time` column that delimits the rubric.
pub fn parse_question(path: &Path) -> Result<Question> {
let number = path
.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| s.parse::<u32>().ok())
.ok_or_else(|| {
Error::Invalid(vec![format!(
"{} is not named like a Gradescope question export (expected `12.csv`)",
path.display()
)])
})?;
// Flexible mode is required: the trailing `Point Values` rows are shorter
// than the header.
let mut reader = csv::ReaderBuilder::new()
.flexible(true)
.has_headers(false)
.from_path(path)
.map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
let mut records = reader.records();
let header = match records.next() {
Some(r) => r.map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?,
None => return Err(Error::Invalid(vec![format!("{} is empty", path.display())])),
};
let header: Vec<String> = header.iter().map(|s| s.trim().to_string()).collect();
let index_of = |name: &str| header.iter().position(|h| h == name);
let start = match index_of("Submission Time") {
Some(i) => i + 1,
None => {
return Err(Error::Invalid(vec![format!(
"{} has no `Submission Time` column, so the rubric columns cannot be located; \
is this a Gradescope per-question export?",
path.display()
)]))
}
};
let end = index_of("Adjustment").unwrap_or(header.len());
if end < start {
return Err(Error::Invalid(vec![format!(
"{} has `Adjustment` before `Submission Time`",
path.display()
)]));
}
let mut columns: Vec<RubricColumn> = header[start..end]
.iter()
.map(|label| {
let (kind, letter, note) = classify(label);
RubricColumn {
label: label.clone(),
kind,
letter,
note,
value: None,
}
})
.collect();
let sid_col = index_of("SID");
let first_col = index_of("First Name");
let last_col = index_of("Last Name");
let email_col = index_of("Email");
let section_col = index_of("Sections");
let score_col = index_of("Score");
let time_col = index_of("Submission Time");
let mut rows = Vec::new();
let mut max_points = 0.0f64;
let mut point_values: Vec<f64> = Vec::new();
let mut scoring_method = None;
for rec in records {
let rec = rec.map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
let cell = |i: Option<usize>| -> Option<String> {
i.and_then(|i| rec.get(i))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
};
if rec.iter().all(|c| c.trim().is_empty()) {
continue;
}
let label = rec.get(0).unwrap_or("").trim();
match label {
"Point Values" => {
// Label, then some empty cells, then the maximum, then one value
// per rubric column. Alignment was verified across every file in
// a real export, but collecting the non-empty numbers in order is
// robust to a leading blank moving.
let nums: Vec<f64> = rec
.iter()
.skip(1)
.filter(|c| !c.trim().is_empty())
.filter_map(|c| c.trim().parse::<f64>().ok())
.collect();
if let Some((first, rest)) = nums.split_first() {
max_points = *first;
point_values = rest.to_vec();
}
continue;
}
"Rubric Numbers" => continue,
"Scoring Method" => {
scoring_method = cell(Some(1));
continue;
}
_ => {}
}
// A student row must have a score cell that parses; anything else is a
// trailing annotation row we have not seen before, and skipping it is
// safer than failing the import.
let score = match cell(score_col).and_then(|s| s.parse::<f64>().ok()) {
Some(s) => s,
None => {
if cell(sid_col).is_none() && cell(email_col).is_none() {
continue;
}
0.0
}
};
let marks: Vec<usize> = (0..columns.len())
.filter(|i| {
rec.get(start + i)
.map(|c| c.trim().eq_ignore_ascii_case("true"))
.unwrap_or(false)
})
.collect();
let name = match (cell(first_col), cell(last_col)) {
(Some(f), Some(l)) => Some(format!("{f} {l}")),
(Some(f), None) => Some(f),
(None, Some(l)) => Some(l),
(None, None) => None,
};
rows.push(StudentRow {
sid: cell(sid_col),
name,
email: cell(email_col),
section: cell(section_col),
score,
submission_time: cell(time_col),
marks,
});
}
// Attach point values when they line up; when they do not, leave them off
// rather than misattribute credit to the wrong option.
if point_values.len() == columns.len() {
for (c, v) in columns.iter_mut().zip(point_values.iter()) {
c.value = Some(*v);
}
}
Ok(Question {
number,
path: path.to_path_buf(),
columns,
max_points,
scoring_method,
rows,
})
}
/// Reads every question file in a directory.
///
/// # Arguments
///
/// * `dir` - the directory of `N.csv` files.
/// * `ctx` - identifying information the export does not carry.
///
/// # Returns
///
/// Normalized responses and the parsed questions.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory cannot be read and [`Error::Invalid`]
/// when it holds no question files.
pub fn ingest_dir(dir: &Path, ctx: &Context) -> Result<Import> {
let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)
.map_err(|e| Error::io(dir, e))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.extension().and_then(|e| e.to_str()) == Some("csv")
&& p.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or(false)
})
.collect();
if paths.is_empty() {
return Err(Error::Invalid(vec![format!(
"{} holds no numbered CSV files; Gradescope exports one file per question, \
named `1.csv` through `N.csv`",
dir.display()
)]));
}
// Numeric order, so `10.csv` does not sort before `2.csv`.
paths.sort_by_key(|p| {
p.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(u32::MAX)
});
let mut questions = Vec::new();
for p in &paths {
questions.push(parse_question(p)?);
}
Ok(to_responses(&questions, ctx))
}
/// Normalizes parsed questions into responses.
///
/// # Arguments
///
/// * `questions` - the parsed question files.
/// * `ctx` - identifying information.
///
/// # Returns
///
/// The import, with warnings for anything that looked wrong but not fatal.
pub fn to_responses(questions: &[Question], ctx: &Context) -> Import {
let mut set = ResponseSet::new();
let admin = administration_id(&ctx.course, &ctx.term, &ctx.assessment_id);
let mut counts: BTreeMap<u32, usize> = BTreeMap::new();
for q in questions {
let keyed: BTreeSet<String> = q.keyed().into_iter().collect();
let points = q.points_possible();
let bonus = q.looks_like_bonus();
if keyed.is_empty() {
set.warnings.push(format!(
"question {}: no keyed option could be identified from the point values, so \
correctness is unknown; scores are still recorded",
q.number
));
}
for (letter, value, note) in q.partial_credit() {
set.warnings.push(format!(
"question {}: option {letter} was awarded {value} of {points} points at grading \
time, which is recorded as an ambiguity signal{}",
q.number,
note.map(|n| format!(" ({n})")).unwrap_or_default()
));
}
for row in &q.rows {
let mut selected = Vec::new();
let mut eliminated = Vec::new();
let mut other = Vec::new();
for &i in &row.marks {
let c = &q.columns[i];
match (c.kind, &c.letter) {
(ColumnKind::Select, Some(l)) => selected.push(l.clone()),
(ColumnKind::Eliminate, Some(l)) => eliminated.push(l.clone()),
_ => other.push(c.label.clone()),
}
}
selected.sort();
eliminated.sort();
let credit = if points > 0.0 {
row.score / points
} else {
0.0
};
let correct = if keyed.is_empty() {
None
} else if selected.is_empty() && eliminated.is_empty() && other.is_empty() {
// A wholly unmarked row is a blank response, not a wrong one.
None
} else {
let chosen: BTreeSet<String> = selected.iter().cloned().collect();
Some(chosen == keyed)
};
let student_key = row
.sid
.clone()
.or_else(|| row.email.clone())
.unwrap_or_else(|| format!("unknown-{}", counts.len()));
*counts.entry(q.number).or_insert(0) += 1;
set.rows.push(Response {
administration_id: admin.clone(),
course: ctx.course.clone(),
term: ctx.term.clone(),
assessment_id: ctx.assessment_id.clone(),
date: ctx.date,
form: ctx.form.clone(),
student_key,
sid: row.sid.clone(),
name: row.name.clone(),
email: row.email.clone(),
section: row.section.clone(),
item_number: q.number,
item_ref: None,
item_version: None,
selected,
eliminated,
correct,
credit,
points_possible: points,
score: row.score,
response_time_seconds: None,
level: None,
learning_objectives: Vec::new(),
topics: Vec::new(),
bonus,
dropped: false,
});
}
}
// Every question should have the same number of submissions. A mismatch
// usually means a student was excused from one question, which is worth
// saying out loud because it changes per-item denominators.
let sizes: BTreeSet<usize> = counts.values().copied().collect();
if sizes.len() > 1 {
let mut odd: Vec<String> = Vec::new();
let modal = *sizes.iter().next_back().unwrap_or(&0);
for (n, c) in &counts {
if *c != modal {
odd.push(format!("q{n} has {c}"));
}
}
set.warnings.push(format!(
"submission counts differ across questions (most have {modal}): {}",
odd.join(", ")
));
}
Import {
responses: set,
questions: questions.to_vec(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_bare_letters() {
let (k, l, n) = classify("C");
assert_eq!(k, ColumnKind::Select);
assert_eq!(l, Some("C".to_string()));
assert_eq!(n, None);
}
#[test]
fn classifies_selected_and_eliminated_prefixes() {
assert_eq!(classify("Selected C").0, ColumnKind::Select);
assert_eq!(classify("Selected C").1, Some("C".to_string()));
assert_eq!(classify("Eliminated A").0, ColumnKind::Eliminate);
assert_eq!(classify("Eliminated A").1, Some("A".to_string()));
}
#[test]
fn keeps_the_instructors_annotation() {
// Verbatim from a real export.
let (k, l, n) = classify(
"B (Technically speaking, this answer describes HBD/HBA, but I can see how this \
distractor is poorly written.)",
);
assert_eq!(k, ColumnKind::Select);
assert_eq!(l, Some("B".to_string()));
assert!(n.unwrap().starts_with("Technically speaking"));
let (_, l2, n2) = classify("B (preparation does not select the one most likely to bind)");
assert_eq!(l2, Some("B".to_string()));
assert!(n2.is_some());
}
#[test]
fn rejects_prose_rubric_rows() {
// Also verbatim: these are genuinely not option columns.
let (k, l, _) = classify("Incorrect selection with no eliminations.");
assert_eq!(k, ColumnKind::Other);
assert_eq!(l, None);
assert_eq!(classify("").0, ColumnKind::Other);
}
#[test]
fn does_not_mistake_a_sentence_for_option_a() {
// Starts with `A`, but the next character is alphanumeric.
assert_eq!(classify("Answered in the margin").0, ColumnKind::Other);
}
fn q(max: f64, cols: &[(&str, f64)]) -> Question {
Question {
number: 1,
path: PathBuf::from("1.csv"),
columns: cols
.iter()
.map(|(label, v)| {
let (kind, letter, note) = classify(label);
RubricColumn {
label: label.to_string(),
kind,
letter,
note,
value: Some(*v),
}
})
.collect(),
max_points: max,
scoring_method: None,
rows: Vec::new(),
}
}
#[test]
fn finds_the_key_by_top_value() {
let question = q(1.5, &[("A", 0.0), ("B", 0.0), ("C", 1.5), ("D", 0.0)]);
assert_eq!(question.keyed(), vec!["C".to_string()]);
assert!(question.partial_credit().is_empty());
}
#[test]
fn finds_the_key_on_a_bonus_question_with_zero_maximum() {
// Real case: q31 and q32 of the sample exam.
let question = q(0.0, &[("A", 0.0), ("B", 1.5), ("C", 0.0)]);
assert!(question.looks_like_bonus());
assert_eq!(question.keyed(), vec!["B".to_string()]);
assert_eq!(question.points_possible(), 1.5);
}
#[test]
fn partial_credit_to_a_distractor_flags_ambiguity() {
// Real case: q14, where B earned 1.49 of 1.5.
let question = q(1.5, &[("A", 0.0), ("B (poorly written)", 1.49), ("C", 1.5)]);
assert_eq!(question.keyed(), vec!["C".to_string()]);
let partial = question.partial_credit();
assert_eq!(partial.len(), 1);
assert_eq!(partial[0].0, "B");
assert!(partial[0].2.is_some(), "keeps the note");
assert_eq!(question.implied_flags(), vec![Flag::Ambiguous]);
}
#[test]
fn detects_elimination_scoring() {
let question = q(
0.9,
&[
("Selected C", 0.9),
("Eliminated A", 0.3),
("Eliminated B", 0.3),
],
);
assert!(question.uses_elimination());
assert_eq!(question.keyed(), vec!["C".to_string()]);
}
#[test]
fn parses_a_realistic_file() {
let dir = std::env::temp_dir().join(format!("cb-gs-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("7.csv");
std::fs::write(
&path,
"Assignment Submission ID,Question Submission ID,First Name,Last Name,SID,Email,\
Sections,Score,Submission Time,A,B,C,D,Adjustment,Comments,Grader,Tags\n\
1,11,Ada,Lovelace,1234567,ada@x.edu,L01,1.5,2026-04-01T10:00:00Z,false,false,true,\
false,,,,\n\
2,12,Alan,Turing,7654321,alan@x.edu,L01,0.0,2026-04-01T10:05:00Z,true,false,false,\
false,,,,\n\
3,13,Grace,Hopper,1111111,grace@x.edu,L02,0.0,2026-04-01T10:06:00Z,false,false,\
false,false,,,,\n\
Point Values,,,,,1.5,0,0,1.5,0\n\
Scoring Method,positive\n",
)
.unwrap();
let question = parse_question(&path).unwrap();
assert_eq!(question.number, 7);
assert_eq!(question.columns.len(), 4, "four rubric columns");
assert_eq!(question.max_points, 1.5);
assert_eq!(question.keyed(), vec!["C".to_string()]);
assert_eq!(question.rows.len(), 3, "trailing rows are not students");
assert_eq!(question.scoring_method.as_deref(), Some("positive"));
let ctx = Context {
course: "BIOSC1540".into(),
term: "2026S".into(),
assessment_id: "exam-4".into(),
date: None,
form: None,
};
let import = to_responses(&[question], &ctx);
let rows = &import.responses.rows;
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].selected, vec!["C".to_string()]);
assert_eq!(rows[0].correct, Some(true));
assert_eq!(rows[0].credit, 1.0);
assert_eq!(rows[1].correct, Some(false));
// A student who marked nothing left it blank; that is not a wrong answer.
assert_eq!(rows[2].correct, None);
assert_eq!(rows[2].selected.len(), 0);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn rejects_a_file_without_the_rubric_delimiter() {
let dir = std::env::temp_dir().join(format!("cb-gs-bad-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("1.csv");
std::fs::write(&path, "Name,Score\nAda,1\n").unwrap();
let err = parse_question(&path).unwrap_err();
assert!(err.to_string().contains("Submission Time"));
std::fs::remove_dir_all(&dir).ok();
}
}
+831
View File
@@ -0,0 +1,831 @@
//! The canonical response row.
//!
//! Every grading platform exports a different shape. Gradescope gives one file
//! per question, in wide form, with a column per rubric item. Canvas gives one
//! enormous file per quiz, in wide form, with two columns per question and answer
//! *text* instead of option letters. Neither shape is analyzable.
//!
//! So both are normalized into the long form defined here: one row per student
//! per item. Long form is what item analysis, IRT, and per-objective mastery all
//! want, it survives a question being added or dropped without changing the
//! schema, and it appends cleanly across terms — which is the whole point, since
//! item statistics only become trustworthy once several administrations are
//! pooled.
//!
//! Two fields deserve comment.
//!
//! `credit` is a *fraction* in `0.0..=1.0`, not points. Storing the fraction
//! keeps the response independent of the points an item happened to be worth on
//! one exam, so pooling across administrations that weighted an item differently
//! is still valid. `score` carries the points actually awarded.
//!
//! `student_key` is whatever identifier analysis should group by, and it may be a
//! pseudonym. The real SID lives in `sid`, which is dropped when pseudonymizing.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::assessment::AssessmentFile;
use crate::catalog::Catalog;
use crate::date::Date;
use crate::hash::pseudonym;
use crate::taxonomy::Level;
/// One student's response to one item on one administration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Response {
/// Identifies this administration: course slug, term, and assessment id.
/// Rows from different administrations of the same exam differ here, which is
/// what makes pooled analysis separable again later.
pub administration_id: String,
/// The course code.
pub course: String,
/// The term, e.g. `2026S`.
pub term: String,
/// The assessment id.
pub assessment_id: String,
/// The administration date.
pub date: Option<Date>,
/// Which form the student took, when forms were used.
pub form: Option<String>,
/// The identifier analysis groups by. A pseudonym when pseudonymizing.
pub student_key: String,
/// The institutional student id, absent when pseudonymized.
pub sid: Option<String>,
/// The student's name, absent when pseudonymized.
pub name: Option<String>,
/// The student's email, absent when pseudonymized.
pub email: Option<String>,
/// Section or lab, kept because it is the grouping most likely to reveal a
/// delivery problem rather than a learning one.
pub section: Option<String>,
/// The question number on the form, which is the join key to the record.
pub item_number: u32,
/// The item's global id, once resolved against an assessment record.
pub item_ref: Option<String>,
/// The item version as administered.
pub item_version: Option<u32>,
/// Option letters the student chose.
pub selected: Vec<String>,
/// Option letters the student eliminated, for elimination-scored items.
pub eliminated: Vec<String>,
/// Whether the response earned full credit. `None` when it cannot be
/// determined, e.g. a blank response on an item with no recorded key.
pub correct: Option<bool>,
/// Fraction of the item's points earned, in `0.0..=1.0`. May exceed nothing
/// and may go negative on elimination scoring.
pub credit: f64,
/// Points the item was worth as administered.
pub points_possible: f64,
/// Points awarded, authoritative from the platform where available.
pub score: f64,
/// Seconds spent, when the platform reports it.
pub response_time_seconds: Option<f64>,
/// The item's level, denormalized so analysis need not carry the catalog.
pub level: Option<Level>,
/// The item's learning objectives, denormalized for per-objective mastery.
pub learning_objectives: Vec<String>,
/// The item's topics, denormalized.
pub topics: Vec<String>,
/// Whether the item was bonus, and so excluded from the scored total.
pub bonus: bool,
/// Whether the item was dropped after the fact.
pub dropped: bool,
}
impl Response {
/// Whether this row should count toward scored totals and item statistics.
///
/// # Returns
///
/// `true` for a scored, undropped item.
pub fn counts(&self) -> bool {
!self.bonus && !self.dropped
}
/// The response coded for a dichotomous model.
///
/// Partial credit is rounded toward the majority: a half-credit response is
/// coded incorrect. IRT here is dichotomous, and pretending otherwise would
/// misstate the model rather than the data.
///
/// # Returns
///
/// `Some(true)` for full credit, `Some(false)` for less, `None` when unknown.
pub fn dichotomous(&self) -> Option<bool> {
match self.correct {
Some(c) => Some(c),
None if self.credit > 0.0 => Some(self.credit >= 0.999),
None => None,
}
}
/// The selected options as a comma-joined string, for flat storage.
pub fn selected_joined(&self) -> String {
self.selected.join(",")
}
}
/// A set of responses plus anything worth telling the user about the ingest.
#[derive(Debug, Clone, Default)]
pub struct ResponseSet {
/// The rows, in ingest order.
pub rows: Vec<Response>,
/// Non-fatal problems: unmatched columns, students with no responses,
/// question numbers absent from the assessment record.
pub warnings: Vec<String>,
}
impl ResponseSet {
/// Creates an empty set.
pub fn new() -> ResponseSet {
ResponseSet::default()
}
/// Appends another set, keeping its warnings.
///
/// # Arguments
///
/// * `other` - the set to absorb.
pub fn absorb(&mut self, other: ResponseSet) {
self.rows.extend(other.rows);
self.warnings.extend(other.warnings);
}
/// The distinct student keys, in sorted order.
pub fn students(&self) -> Vec<String> {
let set: BTreeSet<&str> = self.rows.iter().map(|r| r.student_key.as_str()).collect();
set.into_iter().map(|s| s.to_string()).collect()
}
/// The distinct item numbers that count toward the scored total, sorted.
pub fn scored_items(&self) -> Vec<u32> {
let set: BTreeSet<u32> = self
.rows
.iter()
.filter(|r| r.counts())
.map(|r| r.item_number)
.collect();
set.into_iter().collect()
}
/// All distinct item numbers, sorted.
pub fn all_items(&self) -> Vec<u32> {
let set: BTreeSet<u32> = self.rows.iter().map(|r| r.item_number).collect();
set.into_iter().collect()
}
/// Every row for one item, in student order.
///
/// # Arguments
///
/// * `number` - the question number.
///
/// # Returns
///
/// The matching rows.
pub fn for_item(&self, number: u32) -> Vec<&Response> {
let mut v: Vec<&Response> = self
.rows
.iter()
.filter(|r| r.item_number == number)
.collect();
v.sort_by(|a, b| a.student_key.cmp(&b.student_key));
v
}
/// Every row for one student, in item order.
///
/// # Arguments
///
/// * `key` - the student key.
///
/// # Returns
///
/// The matching rows.
pub fn for_student(&self, key: &str) -> Vec<&Response> {
let mut v: Vec<&Response> = self.rows.iter().filter(|r| r.student_key == key).collect();
v.sort_by_key(|r| r.item_number);
v
}
/// Total points a student earned on scored items.
///
/// # Arguments
///
/// * `key` - the student key.
///
/// # Returns
///
/// The sum of `score` over scored, undropped items.
pub fn scored_total(&self, key: &str) -> f64 {
self.rows
.iter()
.filter(|r| r.student_key == key && r.counts())
.map(|r| r.score)
.sum()
}
/// Bonus points a student earned.
pub fn bonus_total(&self, key: &str) -> f64 {
self.rows
.iter()
.filter(|r| r.student_key == key && r.bonus && !r.dropped)
.map(|r| r.score)
.sum()
}
/// Points available on scored items, taken from the most generous row seen
/// for each item so a student who skipped an item still has a denominator.
pub fn points_available(&self) -> f64 {
let mut per_item: BTreeMap<u32, f64> = BTreeMap::new();
for r in self.rows.iter().filter(|r| r.counts()) {
let e = per_item.entry(r.item_number).or_insert(0.0);
if r.points_possible > *e {
*e = r.points_possible;
}
}
per_item.values().sum()
}
/// Builds the response matrix for psychometrics.
///
/// # Arguments
///
/// * `include_bonus` - whether bonus items belong in the matrix. They
/// normally do not: bonus items are usually hard and optional, so including
/// them inflates the appearance of a low-ability tail.
///
/// # Returns
///
/// The matrix, students by items.
pub fn matrix(&self, include_bonus: bool) -> Matrix {
let students = self.students();
let items: Vec<u32> = if include_bonus {
self.all_items()
.into_iter()
.filter(|n| !self.item_dropped(*n))
.collect()
} else {
self.scored_items()
};
let student_index: BTreeMap<&str, usize> = students
.iter()
.enumerate()
.map(|(i, s)| (s.as_str(), i))
.collect();
let item_index: BTreeMap<u32, usize> =
items.iter().enumerate().map(|(i, n)| (*n, i)).collect();
let mut credit = vec![vec![None; items.len()]; students.len()];
let mut coded = vec![vec![None; items.len()]; students.len()];
for r in &self.rows {
let Some(&si) = student_index.get(r.student_key.as_str()) else {
continue;
};
let Some(&ii) = item_index.get(&r.item_number) else {
continue;
};
credit[si][ii] = Some(r.credit);
coded[si][ii] = r.dichotomous().map(|c| if c { 1u8 } else { 0u8 });
}
Matrix {
students,
items,
credit,
coded,
}
}
/// Whether every row for an item is marked dropped.
///
/// # Arguments
///
/// * `number` - the question number.
///
/// # Returns
///
/// `true` when the item was dropped.
pub fn item_dropped(&self, number: u32) -> bool {
let mut any = false;
for r in self.rows.iter().filter(|r| r.item_number == number) {
any = true;
if !r.dropped {
return false;
}
}
any
}
/// Attaches item metadata from an assessment record and the catalog.
///
/// Ingest knows question numbers; only the record knows which item a number
/// referred to. Doing this as a separate pass means an export can be parsed
/// and inspected before the record is written, which is the order people
/// actually work in.
///
/// # Arguments
///
/// * `record` - the assessment record.
/// * `catalog` - the loaded course, for levels, objectives, and topics.
///
/// # Returns
///
/// Warnings for numbers absent from the record and for keys that disagree
/// with the record.
pub fn enrich(&mut self, record: &AssessmentFile, catalog: Option<&Catalog>) -> Vec<String> {
let mut warnings = Vec::new();
let mut unmatched: BTreeSet<u32> = BTreeSet::new();
let default_points = catalog
.map(|c| c.course.policy.points_per_item)
.unwrap_or(1.0);
for r in &mut self.rows {
let Some(p) = record.placement(r.item_number) else {
unmatched.insert(r.item_number);
continue;
};
r.item_ref = Some(p.item.clone());
r.item_version = p.version;
r.bonus = r.bonus || p.bonus;
r.dropped = r.dropped || p.dropped;
if let Some(points) = p.points {
// The record is authoritative for points as administered; the
// export sometimes carries a stale maximum.
if (points - r.points_possible).abs() > 1e-9 && r.points_possible > 0.0 {
let ratio = r.credit;
r.points_possible = points;
r.score = ratio * points;
}
if r.points_possible == 0.0 {
r.points_possible = points;
}
}
if let Some(cat) = catalog {
if let Some(entry) = cat.get(&p.item) {
r.level = Some(entry.item.level);
r.learning_objectives = if p.learning_objectives.is_empty() {
entry.item.learning_objectives.clone()
} else {
p.learning_objectives.clone()
};
r.topics = entry.item.topics.clone();
if r.points_possible == 0.0 && !p.bonus {
r.points_possible = entry.item.points(default_points);
}
}
} else {
r.level = p.level;
r.learning_objectives = p.learning_objectives.clone();
}
// Apply the record's credit overrides, which is how a decision to
// award partial credit after the fact becomes visible in analysis.
if !p.credit_overrides.is_empty() && r.selected.len() == 1 {
if let Some(over) = p.credit_overrides.get(&r.selected[0]) {
if (*over - r.credit).abs() > 1e-9 {
r.credit = *over;
r.score = *over * r.points_possible;
r.correct = Some(*over >= 0.999);
}
}
}
}
if !unmatched.is_empty() {
let list: Vec<String> = unmatched.iter().map(|n| n.to_string()).collect();
warnings.push(format!(
"question number(s) {} appear in the export but not in the assessment record; \
they will be analyzed without item metadata",
list.join(", ")
));
}
self.warnings.extend(warnings.clone());
warnings
}
/// Replaces identifiers with keyed pseudonyms.
///
/// The salt must be kept outside the course repository. Hashing a seven-digit
/// student id without a key is not de-identification: the whole space can be
/// enumerated in under a second, so anyone with the file recovers every id.
///
/// # Arguments
///
/// * `salt` - the HMAC key.
pub fn pseudonymize(&mut self, salt: &[u8]) {
for r in &mut self.rows {
let source = r
.sid
.clone()
.or_else(|| r.email.clone())
.unwrap_or_else(|| r.student_key.clone());
r.student_key = pseudonym(salt, &source, 12);
r.sid = None;
r.name = None;
r.email = None;
}
}
/// The administration ids present, sorted.
pub fn administrations(&self) -> Vec<String> {
let set: BTreeSet<&str> = self
.rows
.iter()
.map(|r| r.administration_id.as_str())
.collect();
set.into_iter().map(|s| s.to_string()).collect()
}
}
/// Builds an administration id.
///
/// # Arguments
///
/// * `course` - the course code.
/// * `term` - the term.
/// * `assessment` - the assessment id.
///
/// # Returns
///
/// A stable identifier such as `BIOSC1540/2026S/exam-4`.
pub fn administration_id(course: &str, term: &str, assessment: &str) -> String {
format!("{course}/{term}/{assessment}")
}
/// A response matrix, students by items.
#[derive(Debug, Clone)]
pub struct Matrix {
/// Student keys, one per row.
pub students: Vec<String>,
/// Question numbers, one per column.
pub items: Vec<u32>,
/// Credit fractions; `None` for a missing response.
pub credit: Vec<Vec<Option<f64>>>,
/// Dichotomous codes; `None` for a missing response.
pub coded: Vec<Vec<Option<u8>>>,
}
impl Matrix {
/// The number of examinees.
pub fn n_students(&self) -> usize {
self.students.len()
}
/// The number of items.
pub fn n_items(&self) -> usize {
self.items.len()
}
/// Per-student total of credit fractions, treating missing as zero.
///
/// # Returns
///
/// One total per student, in row order.
pub fn totals(&self) -> Vec<f64> {
self.credit
.iter()
.map(|row| row.iter().map(|c| c.unwrap_or(0.0)).sum())
.collect()
}
/// Per-student count of items answered correctly.
pub fn correct_counts(&self) -> Vec<f64> {
self.coded
.iter()
.map(|row| row.iter().map(|c| c.unwrap_or(0) as f64).sum())
.collect()
}
/// The column for one item, by index.
///
/// # Arguments
///
/// * `j` - the column index.
///
/// # Returns
///
/// The dichotomous codes down that column.
pub fn column(&self, j: usize) -> Vec<Option<u8>> {
self.coded.iter().map(|row| row[j]).collect()
}
/// Whether the matrix has enough data to analyze at all.
///
/// # Returns
///
/// `true` when there is at least one student and one item.
pub fn is_analyzable(&self) -> bool {
self.n_students() > 0 && self.n_items() > 0
}
}
/// A flat record for CSV and Parquet storage.
///
/// The nested vectors on [`Response`] do not survive a columnar format, so they
/// are joined here. This is the schema written to disk, and it is deliberately
/// wide and denormalized: it is a fact table, meant to be appended to and read
/// by other tools, not a normalized database.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlatResponse {
/// Identifies the administration.
pub administration_id: String,
/// The course code.
pub course: String,
/// The term.
pub term: String,
/// The assessment id.
pub assessment_id: String,
/// The date as `YYYY-MM-DD`, empty when unknown.
pub date: String,
/// The form id, empty when there were no forms.
pub form: String,
/// The grouping key.
pub student_key: String,
/// The student id, empty when pseudonymized.
pub sid: String,
/// The student email, empty when pseudonymized.
pub email: String,
/// The section, empty when unknown.
pub section: String,
/// The question number.
pub item_number: u32,
/// The item's global id, empty when unresolved.
pub item_ref: String,
/// The item version, 0 when unknown.
pub item_version: u32,
/// Comma-joined selected letters.
pub selected: String,
/// Comma-joined eliminated letters.
pub eliminated: String,
/// `1`, `0`, or empty when unknown.
pub correct: String,
/// Credit fraction.
pub credit: f64,
/// Points possible.
pub points_possible: f64,
/// Points awarded.
pub score: f64,
/// Seconds spent, empty when unknown.
pub response_time_seconds: String,
/// The level code 1..5, 0 when unknown.
pub level: u8,
/// Comma-joined objective ids.
pub learning_objectives: String,
/// Comma-joined topics.
pub topics: String,
/// Whether the item was bonus.
pub bonus: bool,
/// Whether the item was dropped.
pub dropped: bool,
}
impl FlatResponse {
/// Flattens a response.
///
/// # Arguments
///
/// * `r` - the response.
///
/// # Returns
///
/// The flat record.
pub fn from_response(r: &Response) -> FlatResponse {
FlatResponse {
administration_id: r.administration_id.clone(),
course: r.course.clone(),
term: r.term.clone(),
assessment_id: r.assessment_id.clone(),
date: r.date.map(|d| d.to_string()).unwrap_or_default(),
form: r.form.clone().unwrap_or_default(),
student_key: r.student_key.clone(),
sid: r.sid.clone().unwrap_or_default(),
email: r.email.clone().unwrap_or_default(),
section: r.section.clone().unwrap_or_default(),
item_number: r.item_number,
item_ref: r.item_ref.clone().unwrap_or_default(),
item_version: r.item_version.unwrap_or(0),
selected: r.selected.join(","),
eliminated: r.eliminated.join(","),
correct: match r.correct {
Some(true) => "1".to_string(),
Some(false) => "0".to_string(),
None => String::new(),
},
credit: r.credit,
points_possible: r.points_possible,
score: r.score,
response_time_seconds: r
.response_time_seconds
.map(|s| format!("{s:.1}"))
.unwrap_or_default(),
level: r.level.map(|l| l.code()).unwrap_or(0),
learning_objectives: r.learning_objectives.join(","),
topics: r.topics.join(","),
bonus: r.bonus,
dropped: r.dropped,
}
}
/// Rebuilds a response from its flat form.
///
/// # Returns
///
/// The response. Unparseable optional fields become `None` rather than
/// failing the read, because a hand-edited CSV should still load.
pub fn to_response(&self) -> Response {
let split = |s: &str| -> Vec<String> {
s.split(',')
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(|p| p.to_string())
.collect()
};
Response {
administration_id: self.administration_id.clone(),
course: self.course.clone(),
term: self.term.clone(),
assessment_id: self.assessment_id.clone(),
date: self.date.parse().ok(),
form: none_if_empty(&self.form),
student_key: self.student_key.clone(),
sid: none_if_empty(&self.sid),
name: None,
email: none_if_empty(&self.email),
section: none_if_empty(&self.section),
item_number: self.item_number,
item_ref: none_if_empty(&self.item_ref),
item_version: if self.item_version == 0 {
None
} else {
Some(self.item_version)
},
selected: split(&self.selected),
eliminated: split(&self.eliminated),
correct: match self.correct.as_str() {
"1" | "true" => Some(true),
"0" | "false" => Some(false),
_ => None,
},
credit: self.credit,
points_possible: self.points_possible,
score: self.score,
response_time_seconds: self.response_time_seconds.parse().ok(),
level: Level::from_code(self.level),
learning_objectives: split(&self.learning_objectives),
topics: split(&self.topics),
bonus: self.bonus,
dropped: self.dropped,
}
}
}
/// `None` for an empty string, `Some` otherwise.
fn none_if_empty(s: &str) -> Option<String> {
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(student: &str, number: u32, credit: f64) -> Response {
Response {
administration_id: "C/2026S/e1".into(),
course: "C".into(),
term: "2026S".into(),
assessment_id: "e1".into(),
date: None,
form: None,
student_key: student.into(),
sid: Some(format!("sid-{student}")),
name: None,
email: None,
section: None,
item_number: number,
item_ref: None,
item_version: None,
selected: vec!["A".into()],
eliminated: vec![],
correct: Some(credit >= 0.999),
credit,
points_possible: 2.0,
score: credit * 2.0,
response_time_seconds: None,
level: None,
learning_objectives: vec![],
topics: vec![],
bonus: false,
dropped: false,
}
}
#[test]
fn matrix_is_students_by_items() {
let mut set = ResponseSet::new();
set.rows.push(row("s1", 1, 1.0));
set.rows.push(row("s1", 2, 0.0));
set.rows.push(row("s2", 1, 1.0));
set.rows.push(row("s2", 2, 1.0));
let m = set.matrix(false);
assert_eq!(m.n_students(), 2);
assert_eq!(m.n_items(), 2);
assert_eq!(m.coded[0], vec![Some(1), Some(0)]);
assert_eq!(m.correct_counts(), vec![1.0, 2.0]);
}
#[test]
fn missing_responses_stay_missing() {
let mut set = ResponseSet::new();
set.rows.push(row("s1", 1, 1.0));
set.rows.push(row("s2", 2, 1.0));
let m = set.matrix(false);
// s1 never answered item 2, so that cell is absent rather than zero.
assert_eq!(m.coded[0][1], None);
assert_eq!(m.coded[1][0], None);
// Totals treat missing as zero, which is right for scoring.
assert_eq!(m.totals(), vec![1.0, 1.0]);
}
#[test]
fn bonus_items_are_excluded_by_default() {
let mut set = ResponseSet::new();
set.rows.push(row("s1", 1, 1.0));
let mut bonus = row("s1", 2, 1.0);
bonus.bonus = true;
set.rows.push(bonus);
assert_eq!(set.matrix(false).n_items(), 1);
assert_eq!(set.matrix(true).n_items(), 2);
assert_eq!(set.scored_total("s1"), 2.0);
assert_eq!(set.bonus_total("s1"), 2.0);
}
#[test]
fn dropped_items_leave_the_matrix() {
let mut set = ResponseSet::new();
let mut r = row("s1", 1, 0.0);
r.dropped = true;
set.rows.push(r);
set.rows.push(row("s1", 2, 1.0));
assert_eq!(set.matrix(false).items, vec![2]);
assert!(set.item_dropped(1));
assert!(!set.item_dropped(2));
}
#[test]
fn partial_credit_codes_as_incorrect_for_irt() {
let mut r = row("s1", 1, 0.5);
r.correct = None;
assert_eq!(r.dichotomous(), Some(false));
r.credit = 1.0;
assert_eq!(r.dichotomous(), Some(true));
}
#[test]
fn pseudonymizing_removes_identifiers() {
let mut set = ResponseSet::new();
set.rows.push(row("s1", 1, 1.0));
let before = set.rows[0].student_key.clone();
set.pseudonymize(b"secret-salt");
assert_ne!(set.rows[0].student_key, before);
assert!(set.rows[0].student_key.starts_with("s-"));
assert!(set.rows[0].sid.is_none());
assert!(set.rows[0].name.is_none());
}
#[test]
fn flattening_round_trips() {
let r = row("s1", 3, 0.5);
let flat = FlatResponse::from_response(&r);
let back = flat.to_response();
assert_eq!(back.student_key, "s1");
assert_eq!(back.item_number, 3);
assert_eq!(back.credit, 0.5);
assert_eq!(back.selected, vec!["A".to_string()]);
assert_eq!(back.correct, Some(false));
}
#[test]
fn points_available_uses_the_largest_seen_per_item() {
let mut set = ResponseSet::new();
set.rows.push(row("s1", 1, 1.0));
let mut r = row("s2", 1, 1.0);
r.points_possible = 3.0;
set.rows.push(r);
assert_eq!(set.points_available(), 3.0);
}
}
+665
View File
@@ -0,0 +1,665 @@
//! Where response data lives on disk.
//!
//! One file per administration, under `data/`, named after the administration id.
//! Not one big file, because an exam's responses are written once and then only
//! read: separate files mean re-ingesting Exam 4 cannot corrupt Exam 3, and a
//! term's data can be archived or excluded by moving files rather than filtering
//! rows.
//!
//! Parquet is the default format. It is columnar, typed, compressed, and readable
//! by pandas, polars, R, and DuckDB without an export step, which matters because
//! the point of storing this data is to still be able to analyze it in five years
//! with whatever tool exists then.
//!
//! CSV is the fallback, and it is a real fallback rather than a degraded mode:
//! `--no-default-features` builds the entire tool with CSV storage and loses
//! nothing but file size and read speed. Committing to a format you cannot open
//! without the right library version is how course data gets lost.
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::responses::{FlatResponse, Response, ResponseSet};
/// A storage format.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
/// Apache Parquet, the default.
Parquet,
/// Comma-separated values.
Csv,
}
impl Format {
/// The file extension, without a dot.
pub fn extension(self) -> &'static str {
match self {
Format::Parquet => "parquet",
Format::Csv => "csv",
}
}
/// The format compiled in as the default.
///
/// # Returns
///
/// Parquet when the `parquet` feature is on, CSV otherwise.
pub fn preferred() -> Format {
#[cfg(feature = "parquet")]
{
Format::Parquet
}
#[cfg(not(feature = "parquet"))]
{
Format::Csv
}
}
/// Whether this format can be written by the current build.
pub fn is_available(self) -> bool {
match self {
Format::Csv => true,
Format::Parquet => cfg!(feature = "parquet"),
}
}
/// The format implied by a file extension.
///
/// # Arguments
///
/// * `path` - the file path.
///
/// # Returns
///
/// The format, or `None` when the extension is not one we write.
pub fn from_path(path: &Path) -> Option<Format> {
match path.extension().and_then(|e| e.to_str()) {
Some("parquet") => Some(Format::Parquet),
Some("csv") => Some(Format::Csv),
_ => None,
}
}
}
/// The response store rooted at a course's `data/` directory.
#[derive(Debug, Clone)]
pub struct Store {
/// The data directory.
pub dir: PathBuf,
/// The format to write.
pub format: Format,
}
impl Store {
/// Opens a store, creating the directory if needed.
///
/// # Arguments
///
/// * `dir` - the data directory.
///
/// # Returns
///
/// The store, writing the preferred available format.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory cannot be created.
pub fn open(dir: impl Into<PathBuf>) -> Result<Store> {
let dir = dir.into();
std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
Ok(Store {
dir,
format: Format::preferred(),
})
}
/// Sets the format to write.
///
/// # Arguments
///
/// * `format` - the format.
///
/// # Returns
///
/// The store, for chaining.
///
/// # Errors
///
/// Returns [`Error::FeatureDisabled`] when the format was compiled out.
pub fn with_format(mut self, format: Format) -> Result<Store> {
if !format.is_available() {
return Err(Error::FeatureDisabled("Parquet", "parquet"));
}
self.format = format;
Ok(self)
}
/// The path for an administration's responses.
///
/// # Arguments
///
/// * `administration_id` - the administration id.
///
/// # Returns
///
/// The path, with slashes in the id replaced so it is one file.
pub fn path_for(&self, administration_id: &str) -> PathBuf {
self.dir.join(format!(
"{}.{}",
sanitize(administration_id),
self.format.extension()
))
}
/// Writes one administration's responses, replacing any existing file.
///
/// Replacing rather than appending is deliberate. Re-ingesting after a
/// regrade should produce the corrected data, not two contradictory copies of
/// the same student's response, and there is no way to tell those apart later.
///
/// # Arguments
///
/// * `set` - the responses, which must all share one administration id.
///
/// # Returns
///
/// The paths written, one per administration found in the set.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure and [`Error::FeatureDisabled`]
/// when writing Parquet without the feature.
pub fn write(&self, set: &ResponseSet) -> Result<Vec<PathBuf>> {
let mut written = Vec::new();
for admin in set.administrations() {
let rows: Vec<FlatResponse> = set
.rows
.iter()
.filter(|r| r.administration_id == admin)
.map(FlatResponse::from_response)
.collect();
let path = self.path_for(&admin);
match self.format {
Format::Csv => write_csv(&path, &rows)?,
Format::Parquet => write_parquet(&path, &rows)?,
}
written.push(path);
}
Ok(written)
}
/// Reads one administration's responses.
///
/// # Arguments
///
/// * `administration_id` - the administration id.
///
/// # Returns
///
/// The responses.
///
/// # Errors
///
/// Returns [`Error::Io`] when the file is missing.
pub fn read(&self, administration_id: &str) -> Result<ResponseSet> {
// Accept either format regardless of what this build prefers, so a repo
// written by a Parquet build is still readable by a lean one where the
// CSV happens to exist, and vice versa.
let stem = sanitize(administration_id);
for format in [self.format, Format::Parquet, Format::Csv] {
let path = self.dir.join(format!("{stem}.{}", format.extension()));
if path.exists() {
return read_path(&path);
}
}
Err(Error::Other(format!(
"no stored responses for `{administration_id}` in {}",
self.dir.display()
)))
}
/// Every stored data file, sorted.
///
/// # Returns
///
/// The paths.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory cannot be listed.
pub fn files(&self) -> Result<Vec<PathBuf>> {
if !self.dir.exists() {
return Ok(Vec::new());
}
let mut out: Vec<PathBuf> = std::fs::read_dir(&self.dir)
.map_err(|e| Error::io(&self.dir, e))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| Format::from_path(p).is_some())
.collect();
out.sort();
Ok(out)
}
/// Reads every stored administration.
///
/// This is what pooled item statistics run on: several administrations of the
/// same item, which is the only way the numbers become trustworthy for a class
/// of twenty-five.
///
/// # Returns
///
/// All responses, with one file's failure recorded as a warning rather than
/// aborting the rest.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory cannot be listed.
pub fn read_all(&self) -> Result<ResponseSet> {
let mut set = ResponseSet::new();
for path in self.files()? {
match read_path(&path) {
Ok(part) => set.absorb(part),
Err(e) => set
.warnings
.push(format!("skipping {}: {e}", path.display())),
}
}
Ok(set)
}
/// Reads every administration of one assessment, across terms.
///
/// # Arguments
///
/// * `assessment_id` - the assessment id to match.
///
/// # Returns
///
/// The matching responses.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory cannot be listed.
pub fn read_assessment(&self, assessment_id: &str) -> Result<ResponseSet> {
let all = self.read_all()?;
let mut set = ResponseSet::new();
set.warnings = all.warnings;
set.rows = all
.rows
.into_iter()
.filter(|r| r.assessment_id == assessment_id)
.collect();
Ok(set)
}
}
/// Reads a data file, choosing the reader by extension.
///
/// # Arguments
///
/// * `path` - the file.
///
/// # Returns
///
/// The responses.
///
/// # Errors
///
/// Returns [`Error::Other`] for an unrecognized extension and
/// [`Error::FeatureDisabled`] for Parquet without the feature.
pub fn read_path(path: &Path) -> Result<ResponseSet> {
match Format::from_path(path) {
Some(Format::Csv) => read_csv(path),
Some(Format::Parquet) => read_parquet(path),
None => Err(Error::Other(format!(
"{} is not a response file; expected a .parquet or .csv",
path.display()
))),
}
}
/// Writes flat responses as CSV.
///
/// # Arguments
///
/// * `path` - the destination.
/// * `rows` - the rows.
///
/// # Errors
///
/// Returns [`Error::Csv`] on a serialization failure.
fn write_csv(path: &Path, rows: &[FlatResponse]) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
}
let mut w = csv::Writer::from_path(path).map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
for r in rows {
w.serialize(r).map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
}
w.flush().map_err(|e| Error::io(path, e))?;
Ok(())
}
/// Reads flat responses from CSV.
///
/// # Arguments
///
/// * `path` - the file.
///
/// # Returns
///
/// The responses.
///
/// # Errors
///
/// Returns [`Error::Csv`] on a parse failure.
fn read_csv(path: &Path) -> Result<ResponseSet> {
let mut r = csv::Reader::from_path(path).map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
let mut set = ResponseSet::new();
for rec in r.deserialize::<FlatResponse>() {
let flat = rec.map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
set.rows.push(flat.to_response());
}
Ok(set)
}
/// Writes flat responses as Parquet.
///
/// # Arguments
///
/// * `path` - the destination.
/// * `rows` - the rows.
///
/// # Errors
///
/// Returns [`Error::FeatureDisabled`] when the feature is off.
#[cfg(feature = "parquet")]
fn write_parquet(path: &Path, rows: &[FlatResponse]) -> Result<()> {
crate::store_parquet::write(path, rows)
}
/// Stub for builds without Parquet support.
#[cfg(not(feature = "parquet"))]
fn write_parquet(_path: &Path, _rows: &[FlatResponse]) -> Result<()> {
Err(Error::FeatureDisabled("Parquet", "parquet"))
}
/// Reads flat responses from Parquet.
///
/// # Arguments
///
/// * `path` - the file.
///
/// # Returns
///
/// The responses.
///
/// # Errors
///
/// Returns [`Error::FeatureDisabled`] when the feature is off.
#[cfg(feature = "parquet")]
fn read_parquet(path: &Path) -> Result<ResponseSet> {
let rows = crate::store_parquet::read(path)?;
let mut set = ResponseSet::new();
set.rows = rows.iter().map(|f| f.to_response()).collect();
Ok(set)
}
/// Stub for builds without Parquet support.
#[cfg(not(feature = "parquet"))]
fn read_parquet(path: &Path) -> Result<ResponseSet> {
Err(Error::Other(format!(
"{} is a Parquet file, but this build has Parquet support compiled out; \
rebuild with `--features parquet`, or re-ingest with `--format csv`",
path.display()
)))
}
/// Makes an administration id usable as a file name.
///
/// # Arguments
///
/// * `s` - the id.
///
/// # Returns
///
/// The sanitized stem.
pub fn sanitize(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut last_sep = false;
for ch in s.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '.' {
out.push(ch.to_ascii_lowercase());
last_sep = false;
} else if !last_sep {
out.push('_');
last_sep = true;
}
}
let trimmed = out.trim_matches('_').to_string();
if trimmed.is_empty() {
"responses".to_string()
} else {
trimmed
}
}
/// Exports responses to an arbitrary path, in the format its extension implies.
///
/// This exists so `export` is a separate verb from `ingest`: the store is the
/// system of record, and handing a colleague a CSV should not change it.
///
/// # Arguments
///
/// * `path` - the destination.
/// * `set` - the responses.
///
/// # Errors
///
/// Returns [`Error::Other`] for an unrecognized extension.
pub fn export(path: &Path, set: &ResponseSet) -> Result<()> {
let rows: Vec<FlatResponse> = set.rows.iter().map(FlatResponse::from_response).collect();
match Format::from_path(path) {
Some(Format::Csv) => write_csv(path, &rows),
Some(Format::Parquet) => write_parquet(path, &rows),
None => Err(Error::Other(format!(
"cannot tell what format {} should be; use a .csv or .parquet extension",
path.display()
))),
}
}
/// Summarizes what is in the store, for `coursebank data list`.
#[derive(Debug, Clone)]
pub struct StoredSummary {
/// The administration id.
pub administration_id: String,
/// The file.
pub path: PathBuf,
/// How many response rows.
pub rows: usize,
/// How many distinct students.
pub students: usize,
/// How many distinct items.
pub items: usize,
}
/// Summarizes every stored administration.
///
/// # Arguments
///
/// * `store` - the store.
///
/// # Returns
///
/// One summary per file, sorted by path.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory cannot be listed.
pub fn summarize(store: &Store) -> Result<Vec<StoredSummary>> {
let mut out = Vec::new();
for path in store.files()? {
let set = match read_path(&path) {
Ok(s) => s,
Err(_) => continue,
};
let admin = set.administrations().first().cloned().unwrap_or_else(|| {
path.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into()
});
out.push(StoredSummary {
administration_id: admin,
path,
rows: set.rows.len(),
students: set.students().len(),
items: set.all_items().len(),
});
}
Ok(out)
}
/// Groups responses by administration.
///
/// # Arguments
///
/// * `set` - the responses.
///
/// # Returns
///
/// One set per administration, keyed by id.
pub fn split_by_administration(
set: &ResponseSet,
) -> std::collections::BTreeMap<String, Vec<&Response>> {
let mut out: std::collections::BTreeMap<String, Vec<&Response>> = Default::default();
for r in &set.rows {
out.entry(r.administration_id.clone()).or_default().push(r);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::responses::Response;
fn row(student: &str, number: u32) -> Response {
Response {
administration_id: "BIOSC1540/2026S/exam-4".into(),
course: "BIOSC1540".into(),
term: "2026S".into(),
assessment_id: "exam-4".into(),
date: None,
form: None,
student_key: student.into(),
sid: None,
name: None,
email: None,
section: None,
item_number: number,
item_ref: Some("bank::q-x-001".into()),
item_version: Some(2),
selected: vec!["C".into()],
eliminated: vec![],
correct: Some(true),
credit: 1.0,
points_possible: 1.5,
score: 1.5,
response_time_seconds: None,
level: None,
learning_objectives: vec!["lo-a".into()],
topics: vec![],
bonus: false,
dropped: false,
}
}
#[test]
fn sanitizes_administration_ids() {
assert_eq!(sanitize("BIOSC1540/2026S/exam-4"), "biosc1540_2026s_exam-4");
assert_eq!(sanitize("///"), "responses");
// Runs of separators collapse rather than stacking underscores.
assert_eq!(sanitize("a // b"), "a_b");
}
#[test]
fn csv_round_trips_through_the_store() {
let dir = std::env::temp_dir().join(format!("cb-store-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
let store = Store::open(&dir).unwrap().with_format(Format::Csv).unwrap();
let mut set = ResponseSet::new();
set.rows.push(row("s1", 1));
set.rows.push(row("s2", 1));
let written = store.write(&set).unwrap();
assert_eq!(written.len(), 1);
assert!(written[0].exists());
let back = store.read("BIOSC1540/2026S/exam-4").unwrap();
assert_eq!(back.rows.len(), 2);
assert_eq!(back.rows[0].item_ref.as_deref(), Some("bank::q-x-001"));
assert_eq!(back.rows[0].selected, vec!["C".to_string()]);
assert_eq!(back.rows[0].learning_objectives, vec!["lo-a".to_string()]);
let all = store.read_all().unwrap();
assert_eq!(all.rows.len(), 2);
let summaries = summarize(&store).unwrap();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].students, 2);
assert_eq!(summaries[0].items, 1);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn rewriting_replaces_rather_than_duplicates() {
let dir = std::env::temp_dir().join(format!("cb-store-rw-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
let store = Store::open(&dir).unwrap().with_format(Format::Csv).unwrap();
let mut set = ResponseSet::new();
set.rows.push(row("s1", 1));
store.write(&set).unwrap();
store.write(&set).unwrap();
assert_eq!(store.read_all().unwrap().rows.len(), 1, "no duplicates");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn missing_administrations_report_clearly() {
let dir = std::env::temp_dir().join(format!("cb-store-miss-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
let store = Store::open(&dir).unwrap().with_format(Format::Csv).unwrap();
let err = store.read("nope").unwrap_err();
assert!(err.to_string().contains("no stored responses"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn format_extensions_round_trip() {
assert_eq!(
Format::from_path(Path::new("a/b.parquet")),
Some(Format::Parquet)
);
assert_eq!(Format::from_path(Path::new("a/b.csv")), Some(Format::Csv));
assert_eq!(Format::from_path(Path::new("a/b.yaml")), None);
assert!(Format::Csv.is_available());
}
}
+387
View File
@@ -0,0 +1,387 @@
//! Parquet reading and writing, isolated so the rest of the crate never touches
//! Arrow types.
//!
//! Every use of `arrow` and `parquet` in this crate is in this file. That is
//! deliberate: those two crates move fast and release breaking versions together,
//! and confining them to one module means a version bump is a single-file edit
//! rather than a refactor. It also means `--no-default-features` compiles the
//! whole tool without them.
//!
//! The schema mirrors [`FlatResponse`] field for field. Optional values are
//! written with the same sentinels the CSV path uses — empty string, `0` for an
//! unknown level — rather than nulls, so that a Parquet file and a CSV file of
//! the same data load identically in pandas.
use std::path::Path;
use std::sync::Arc;
use arrow_array::{ArrayRef, BooleanArray, Float64Array, RecordBatch, StringArray, UInt32Array};
use arrow_schema::{DataType, Field, Schema};
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
use parquet::arrow::ArrowWriter;
use parquet::basic::Compression;
use parquet::file::properties::WriterProperties;
use crate::error::{Error, Result};
use crate::responses::FlatResponse;
/// The Arrow schema for stored responses.
///
/// # Returns
///
/// The schema, whose field order matches [`FlatResponse`].
pub fn schema() -> Schema {
Schema::new(vec![
Field::new("administration_id", DataType::Utf8, false),
Field::new("course", DataType::Utf8, false),
Field::new("term", DataType::Utf8, false),
Field::new("assessment_id", DataType::Utf8, false),
Field::new("date", DataType::Utf8, false),
Field::new("form", DataType::Utf8, false),
Field::new("student_key", DataType::Utf8, false),
Field::new("sid", DataType::Utf8, false),
Field::new("email", DataType::Utf8, false),
Field::new("section", DataType::Utf8, false),
Field::new("item_number", DataType::UInt32, false),
Field::new("item_ref", DataType::Utf8, false),
Field::new("item_version", DataType::UInt32, false),
Field::new("selected", DataType::Utf8, false),
Field::new("eliminated", DataType::Utf8, false),
Field::new("correct", DataType::Utf8, false),
Field::new("credit", DataType::Float64, false),
Field::new("points_possible", DataType::Float64, false),
Field::new("score", DataType::Float64, false),
Field::new("response_time_seconds", DataType::Utf8, false),
Field::new("level", DataType::UInt32, false),
Field::new("learning_objectives", DataType::Utf8, false),
Field::new("topics", DataType::Utf8, false),
Field::new("bonus", DataType::Boolean, false),
Field::new("dropped", DataType::Boolean, false),
])
}
/// Builds a record batch from flat responses.
///
/// # Arguments
///
/// * `rows` - the rows.
///
/// # Returns
///
/// The batch.
///
/// # Errors
///
/// Returns [`Error::Other`] when Arrow rejects the column set, which would mean
/// this function and [`schema`] have drifted apart.
fn to_batch(rows: &[FlatResponse]) -> Result<RecordBatch> {
let s = |f: fn(&FlatResponse) -> &str| -> ArrayRef {
Arc::new(StringArray::from(rows.iter().map(f).collect::<Vec<&str>>()))
};
let f64c = |f: fn(&FlatResponse) -> f64| -> ArrayRef {
Arc::new(Float64Array::from(rows.iter().map(f).collect::<Vec<f64>>()))
};
let u32c = |f: fn(&FlatResponse) -> u32| -> ArrayRef {
Arc::new(UInt32Array::from(rows.iter().map(f).collect::<Vec<u32>>()))
};
let boolc = |f: fn(&FlatResponse) -> bool| -> ArrayRef {
Arc::new(BooleanArray::from(
rows.iter().map(f).collect::<Vec<bool>>(),
))
};
let columns: Vec<ArrayRef> = vec![
s(|r| &r.administration_id),
s(|r| &r.course),
s(|r| &r.term),
s(|r| &r.assessment_id),
s(|r| &r.date),
s(|r| &r.form),
s(|r| &r.student_key),
s(|r| &r.sid),
s(|r| &r.email),
s(|r| &r.section),
u32c(|r| r.item_number),
s(|r| &r.item_ref),
u32c(|r| r.item_version),
s(|r| &r.selected),
s(|r| &r.eliminated),
s(|r| &r.correct),
f64c(|r| r.credit),
f64c(|r| r.points_possible),
f64c(|r| r.score),
s(|r| &r.response_time_seconds),
u32c(|r| r.level as u32),
s(|r| &r.learning_objectives),
s(|r| &r.topics),
boolc(|r| r.bonus),
boolc(|r| r.dropped),
];
RecordBatch::try_new(Arc::new(schema()), columns).map_err(|e| {
Error::Other(format!(
"internal error building a Parquet batch: {e}. This means the Arrow schema and the \
column builders in store_parquet.rs have drifted apart"
))
})
}
/// Writes flat responses to a Parquet file.
///
/// # Arguments
///
/// * `path` - the destination.
/// * `rows` - the rows.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure and [`Error::Other`] when the
/// Parquet writer rejects the batch.
pub fn write(path: &Path, rows: &[FlatResponse]) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
}
let batch = to_batch(rows)?;
let file = std::fs::File::create(path).map_err(|e| Error::io(path, e))?;
// Snappy rather than zstd: it is the format's most universally readable
// codec, and these files are small enough that the ratio difference is
// irrelevant next to being openable by an old pandas.
let props = WriterProperties::builder()
.set_compression(Compression::SNAPPY)
.build();
let mut writer = ArrowWriter::try_new(file, Arc::new(schema()), Some(props))
.map_err(|e| Error::Other(format!("cannot open {} for Parquet: {e}", path.display())))?;
writer
.write(&batch)
.map_err(|e| Error::Other(format!("cannot write {}: {e}", path.display())))?;
writer
.close()
.map_err(|e| Error::Other(format!("cannot finish {}: {e}", path.display())))?;
Ok(())
}
/// Reads flat responses from a Parquet file.
///
/// # Arguments
///
/// * `path` - the file.
///
/// # Returns
///
/// The rows.
///
/// # Errors
///
/// Returns [`Error::Io`] when the file cannot be opened and [`Error::Other`]
/// when its schema does not match what this crate writes.
pub fn read(path: &Path) -> Result<Vec<FlatResponse>> {
let file = std::fs::File::open(path).map_err(|e| Error::io(path, e))?;
let builder = ParquetRecordBatchReaderBuilder::try_new(file)
.map_err(|e| Error::Other(format!("cannot read {} as Parquet: {e}", path.display())))?;
let reader = builder
.build()
.map_err(|e| Error::Other(format!("cannot read {}: {e}", path.display())))?;
let mut out = Vec::new();
for batch in reader {
let batch =
batch.map_err(|e| Error::Other(format!("cannot read {}: {e}", path.display())))?;
out.extend(from_batch(&batch, path)?);
}
Ok(out)
}
/// Converts a record batch back into flat responses.
///
/// # Arguments
///
/// * `batch` - the batch.
/// * `path` - the source file, for error messages.
///
/// # Returns
///
/// The rows.
///
/// # Errors
///
/// Returns [`Error::Other`] when a column is missing or has the wrong type.
fn from_batch(batch: &RecordBatch, path: &Path) -> Result<Vec<FlatResponse>> {
let strings = |name: &str| -> Result<&StringArray> {
batch
.column_by_name(name)
.and_then(|c| c.as_any().downcast_ref::<StringArray>())
.ok_or_else(|| column_error(name, "string", path))
};
let floats = |name: &str| -> Result<&Float64Array> {
batch
.column_by_name(name)
.and_then(|c| c.as_any().downcast_ref::<Float64Array>())
.ok_or_else(|| column_error(name, "float64", path))
};
let uints = |name: &str| -> Result<&UInt32Array> {
batch
.column_by_name(name)
.and_then(|c| c.as_any().downcast_ref::<UInt32Array>())
.ok_or_else(|| column_error(name, "uint32", path))
};
let bools = |name: &str| -> Result<&BooleanArray> {
batch
.column_by_name(name)
.and_then(|c| c.as_any().downcast_ref::<BooleanArray>())
.ok_or_else(|| column_error(name, "boolean", path))
};
let administration_id = strings("administration_id")?;
let course = strings("course")?;
let term = strings("term")?;
let assessment_id = strings("assessment_id")?;
let date = strings("date")?;
let form = strings("form")?;
let student_key = strings("student_key")?;
let sid = strings("sid")?;
let email = strings("email")?;
let section = strings("section")?;
let item_number = uints("item_number")?;
let item_ref = strings("item_ref")?;
let item_version = uints("item_version")?;
let selected = strings("selected")?;
let eliminated = strings("eliminated")?;
let correct = strings("correct")?;
let credit = floats("credit")?;
let points_possible = floats("points_possible")?;
let score = floats("score")?;
let response_time_seconds = strings("response_time_seconds")?;
let level = uints("level")?;
let learning_objectives = strings("learning_objectives")?;
let topics = strings("topics")?;
let bonus = bools("bonus")?;
let dropped = bools("dropped")?;
let mut out = Vec::with_capacity(batch.num_rows());
for i in 0..batch.num_rows() {
out.push(FlatResponse {
administration_id: administration_id.value(i).to_string(),
course: course.value(i).to_string(),
term: term.value(i).to_string(),
assessment_id: assessment_id.value(i).to_string(),
date: date.value(i).to_string(),
form: form.value(i).to_string(),
student_key: student_key.value(i).to_string(),
sid: sid.value(i).to_string(),
email: email.value(i).to_string(),
section: section.value(i).to_string(),
item_number: item_number.value(i),
item_ref: item_ref.value(i).to_string(),
item_version: item_version.value(i),
selected: selected.value(i).to_string(),
eliminated: eliminated.value(i).to_string(),
correct: correct.value(i).to_string(),
credit: credit.value(i),
points_possible: points_possible.value(i),
score: score.value(i),
response_time_seconds: response_time_seconds.value(i).to_string(),
level: level.value(i) as u8,
learning_objectives: learning_objectives.value(i).to_string(),
topics: topics.value(i).to_string(),
bonus: bonus.value(i),
dropped: dropped.value(i),
});
}
Ok(out)
}
/// Builds the error for a missing or mistyped column.
fn column_error(name: &str, expected: &str, path: &Path) -> Error {
Error::Other(format!(
"{} is missing the `{name}` column or it is not {expected}; this file was probably not \
written by coursebank",
path.display()
))
}
#[cfg(test)]
mod tests {
use super::*;
fn flat(student: &str, number: u32) -> FlatResponse {
FlatResponse {
administration_id: "C/2026S/e1".into(),
course: "C".into(),
term: "2026S".into(),
assessment_id: "e1".into(),
date: "2026-04-01".into(),
form: String::new(),
student_key: student.into(),
sid: "1234567".into(),
email: String::new(),
section: "L01".into(),
item_number: number,
item_ref: "bank::q-a-001".into(),
item_version: 3,
selected: "C".into(),
eliminated: String::new(),
correct: "1".into(),
credit: 1.0,
points_possible: 1.5,
score: 1.5,
response_time_seconds: "42.0".into(),
level: 3,
learning_objectives: "lo-a,lo-b".into(),
topics: "kinetics".into(),
bonus: false,
dropped: false,
}
}
#[test]
fn schema_matches_the_column_builders() {
let rows = vec![flat("s1", 1)];
let batch = to_batch(&rows).expect("schema and builders agree");
assert_eq!(batch.num_columns(), schema().fields().len());
assert_eq!(batch.num_rows(), 1);
}
#[test]
fn round_trips_through_a_file() {
let dir = std::env::temp_dir().join(format!("cb-pq-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("e1.parquet");
let rows = vec![flat("s1", 1), flat("s2", 2)];
write(&path, &rows).unwrap();
let back = read(&path).unwrap();
assert_eq!(back.len(), 2);
assert_eq!(back[0].student_key, "s1");
assert_eq!(back[1].item_number, 2);
assert_eq!(back[0].learning_objectives, "lo-a,lo-b");
assert_eq!(back[0].credit, 1.0);
assert_eq!(back[0].level, 3);
assert!(!back[0].bonus);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_empty_batch_is_writable() {
let dir = std::env::temp_dir().join(format!("cb-pq-empty-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("empty.parquet");
write(&path, &[]).unwrap();
assert_eq!(read(&path).unwrap().len(), 0);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_foreign_file_is_rejected_clearly() {
let dir = std::env::temp_dir().join(format!("cb-pq-bad-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("not.parquet");
std::fs::write(&path, b"this is not parquet").unwrap();
let err = read(&path).unwrap_err();
assert!(err.to_string().contains("as Parquet"));
std::fs::remove_dir_all(&dir).ok();
}
}
+129
View File
@@ -0,0 +1,129 @@
//! One error type for the whole crate.
//!
//! Every fallible operation returns [`Error`]. Variants carry the path or
//! identifier at fault so a message is actionable without a backtrace. The
//! [`Error::Invalid`] variant carries a list of problems rather than one,
//! because validation is meant to report everything wrong with a file in a
//! single pass instead of making the author fix one issue per run.
use std::path::PathBuf;
/// The crate result alias.
pub type Result<T> = std::result::Result<T, Error>;
/// Anything that can go wrong loading, validating, or transforming course data.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// A file could not be read or written.
#[error("cannot read or write {path}: {source}")]
Io {
/// The offending path.
path: PathBuf,
/// The underlying I/O failure.
source: std::io::Error,
},
/// An I/O failure with no natural path to attach.
#[error("i/o error: {0}")]
BareIo(#[from] std::io::Error),
/// A YAML file did not match the schema.
#[error("{path} is not valid coursebank YAML: {source}")]
Yaml {
/// The offending path.
path: PathBuf,
/// The parse failure, which includes a line and column.
source: serde_yaml_ng::Error,
},
/// A JSON file did not parse.
#[error("{path} is not valid JSON: {source}")]
Json {
/// The offending path.
path: PathBuf,
/// The parse failure.
source: serde_json::Error,
},
/// A CSV file did not parse.
#[error("{path} is not valid CSV: {source}")]
Csv {
/// The offending path.
path: PathBuf,
/// The parse failure.
source: csv::Error,
},
/// A file was structurally fine but semantically wrong. Holds every problem
/// found so one run fixes one file.
#[error("{} problem(s) found:\n{}", .0.len(), format_issues(.0))]
Invalid(Vec<String>),
/// A reference did not resolve: an unknown objective, lecture, item, or bank.
#[error("unknown {kind} `{id}`{}", context_suffix(.context))]
Unresolved {
/// What kind of thing was referenced, e.g. `"learning objective"`.
kind: &'static str,
/// The identifier that did not resolve.
id: String,
/// Where the dangling reference was found, if known.
context: Option<String>,
},
/// The requested selection could not be satisfied from the available items.
#[error("cannot satisfy the blueprint: {0}")]
Infeasible(String),
/// A date string was not `YYYY-MM-DD`, or was not a real calendar date.
#[error("`{0}` is not a date in YYYY-MM-DD form")]
BadDate(String),
/// A command-line argument was well-formed but unusable.
#[error("{0}")]
Usage(String),
/// A capability was compiled out.
#[error("{0} support was not compiled in; rebuild with `--features {1}`")]
FeatureDisabled(&'static str, &'static str),
/// Something went wrong that does not deserve its own variant.
#[error("{0}")]
Other(String),
}
impl Error {
/// Wraps an I/O failure with the path that caused it.
pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Error {
Error::Io {
path: path.into(),
source,
}
}
/// Builds an [`Error::Other`] from anything displayable.
pub fn other(msg: impl std::fmt::Display) -> Error {
Error::Other(msg.to_string())
}
/// Builds an [`Error::Usage`] from anything displayable.
pub fn usage(msg: impl std::fmt::Display) -> Error {
Error::Usage(msg.to_string())
}
}
/// Renders an issue list as an indented bullet list.
fn format_issues(issues: &[String]) -> String {
issues
.iter()
.map(|i| format!(" - {i}"))
.collect::<Vec<_>>()
.join("\n")
}
/// Renders the optional context of an unresolved reference.
fn context_suffix(context: &Option<String>) -> String {
match context {
Some(c) => format!(" (referenced by {c})"),
None => String::new(),
}
}
+21
View File
@@ -0,0 +1,21 @@
//! Turning course data into documents.
//!
//! | Module | Produces | For |
//! |:--|:--|:--|
//! | [`qti`] | a QTI 1.2 zip | importing into Canvas |
//! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet |
//! | [`report`] | Markdown and HTML | students, and yourself |
//!
//! [`qti`] and [`typst`] share one rule that is easy to get wrong: a form's answer
//! key must be generated from the same permutation that produced its question
//! paper. Both derive option order from the form's recorded seed rather than
//! storing it, so every export of form B agrees with every other.
//!
//! [`report`] writes two documents with different content, not different tones. The
//! student report answers "what should I do next?" and deliberately omits correct
//! answers, other students' data, and any numeric rank.
//! The instructor report answers "what should I fix?" and holds the item statistics.
pub mod qti;
pub mod report;
pub mod typst;
+686
View File
@@ -0,0 +1,686 @@
//! Exporting an assessment as a Canvas-importable QTI 1.2 package.
//!
//! QTI 1.2 is a fussy, half-abandoned standard, and Canvas reads a particular
//! dialect of it. Two details are worth recording because they are easy to get
//! wrong and produce silent misbehavior rather than an import error.
//!
//! First, question HTML lives inside `<mattext texttype="text/html">` as
//! character data, which means it is escaped once on the way in and unescaped
//! once by Canvas. So `&rarr;` is written as `&amp;rarr;`. Skipping that step
//! produces XML that parses and renders as literal `&rarr;` to students.
//!
//! Second, identifiers must be stable. Canvas keys re-imports and question banks
//! off them, so a package regenerated after a typo fix should carry the same ids
//! as the original. Every id here is derived by hashing the assessment id
//! together with the item's global id, never from a clock or a counter.
//!
//! Option order comes from the form, so exporting form A and form B of the same
//! assessment gives two packages that ask the same questions in different orders,
//! and the answer keys are guaranteed to agree with what was printed.
use crate::assessment::{AssessmentFile, Form, ScoringPolicy};
use crate::catalog::Catalog;
use crate::error::{Error, Result};
use crate::hash::{hex, sha256};
use crate::item::Item;
use crate::markup;
use crate::select;
use crate::taxonomy::Format;
use crate::zipfile::ZipBuilder;
/// The QTI 1.2 namespace.
const QTI_NS: &str = "http://www.imsglobal.org/xsd/ims_qtiasiv1p2";
/// The schema location Canvas expects alongside it.
const QTI_SCHEMA: &str = "http://www.imsglobal.org/xsd/ims_qtiasiv1p2 \
http://www.imsglobal.org/xsd/ims_qtiasiv1p2p1.xsd";
/// The XML Schema instance namespace.
const XSI_NS: &str = "http://www.w3.org/2001/XMLSchema-instance";
/// The IMS content packaging namespace.
const IMSCP_NS: &str = "http://www.imsglobal.org/xsd/imscp_v1p1";
/// The IMS metadata namespace.
const IMSMD_NS: &str = "http://www.imsglobal.org/xsd/imsmd_v1p2";
/// The content packaging schema location.
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)]
struct Node {
tag: String,
attrs: Vec<(String, String)>,
text: Option<String>,
children: Vec<Node>,
}
impl Node {
/// Creates an element with no attributes, text, or children.
fn new(tag: &str) -> Node {
Node {
tag: tag.to_string(),
attrs: Vec::new(),
text: None,
children: Vec::new(),
}
}
/// Adds an attribute, returning self for chaining.
fn attr(mut self, k: &str, v: impl Into<String>) -> Node {
self.attrs.push((k.to_string(), v.into()));
self
}
/// Sets the element text, returning self for chaining.
fn text(mut self, t: impl Into<String>) -> Node {
self.text = Some(t.into());
self
}
/// Appends a child, returning self for chaining.
fn child(mut self, c: Node) -> Node {
self.children.push(c);
self
}
/// Appends several children, returning self for chaining.
fn children(mut self, cs: Vec<Node>) -> Node {
self.children.extend(cs);
self
}
/// Renders the element and its subtree.
///
/// # Arguments
///
/// * `depth` - the indentation level.
///
/// # Returns
///
/// Indented XML, newline-terminated.
fn render(&self, depth: usize) -> String {
let pad = " ".repeat(depth);
let mut attrs = String::new();
for (k, v) in &self.attrs {
attrs.push_str(&format!(" {k}=\"{}\"", escape_attr(v)));
}
if self.children.is_empty() {
match &self.text {
None => format!("{pad}<{}{attrs}/>\n", self.tag),
Some(t) => format!(
"{pad}<{}{attrs}>{}</{}>\n",
self.tag,
escape_text(t),
self.tag
),
}
} else {
let mut out = format!("{pad}<{}{attrs}>\n", self.tag);
if let Some(t) = &self.text {
out.push_str(&format!("{pad} {}\n", escape_text(t)));
}
for c in &self.children {
out.push_str(&c.render(depth + 1));
}
out.push_str(&format!("{pad}</{}>\n", self.tag));
out
}
}
/// Renders a complete document with an XML declaration.
fn document(&self) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{}",
self.render(0)
)
}
}
/// Escapes text content.
fn escape_text(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// Escapes an attribute value.
fn escape_attr(s: &str) -> String {
escape_text(s).replace('"', "&quot;")
}
// ---------------------------------------------------------------------------
// Package construction
// ---------------------------------------------------------------------------
/// Options for a QTI export.
#[derive(Debug, Clone)]
pub struct QtiOptions {
/// Which form's option order to use.
pub form: Form,
/// Whether to include per-option feedback. Turn it off for a practice quiz
/// you intend to reuse as a graded one, since Canvas shows this feedback
/// immediately.
pub include_feedback: bool,
/// Whether to let Canvas shuffle answers on top of the form's own order.
pub shuffle_in_canvas: bool,
/// Maximum attempts; `-1` for unlimited.
pub attempts: i64,
/// How repeated attempts are scored.
pub scoring_policy: ScoringPolicy,
}
impl Default for QtiOptions {
fn default() -> QtiOptions {
QtiOptions {
form: Form {
id: "A".to_string(),
seed: 0,
shuffle_items: false,
shuffle_options: false,
},
include_feedback: true,
shuffle_in_canvas: false,
attempts: 1,
scoring_policy: ScoringPolicy::KeepHighest,
}
}
}
/// A rendered QTI package, ready to write.
#[derive(Debug, Clone)]
pub struct Package {
/// The name of the quiz XML file inside the archive.
pub quiz_filename: String,
/// The quiz XML.
pub quiz_xml: String,
/// The manifest XML.
pub manifest_xml: String,
}
impl Package {
/// Writes the package as a Canvas-importable zip.
///
/// # Arguments
///
/// * `path` - the destination `.zip` path.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure.
pub fn write_zip(&self, path: &std::path::Path) -> Result<()> {
let mut z = ZipBuilder::new();
// Canvas only recognizes the archive as QTI when the manifest sits at the
// root rather than inside a directory.
z.add_text("imsmanifest.xml", &self.manifest_xml);
z.add_text(&self.quiz_filename, &self.quiz_xml);
z.write_to(path)
}
}
/// Builds a QTI package for an assessment.
///
/// # Arguments
///
/// * `catalog` - the loaded course, for resolving items.
/// * `record` - the assessment record.
/// * `opts` - export options.
///
/// # Returns
///
/// The rendered package.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when a placement references a missing item, and
/// [`Error::Invalid`] when an item cannot be represented in QTI, such as one with
/// no keyed option.
pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> Result<Package> {
let assessment_id = qti_id(&format!("{}/assessment", record.assessment.id));
let default_points = catalog.course.policy.points_per_item;
let mut problems = Vec::new();
let mut items = Vec::new();
for placement in select::layout(record, &opts.form) {
let entry = catalog.require(&placement.item)?;
let item = &entry.item;
if item.key_indices().is_empty() {
problems.push(format!(
"question {} ({}) has no keyed option, so Canvas cannot score it",
placement.number, placement.item
));
continue;
}
let points = placement
.points
.unwrap_or_else(|| item.points(default_points));
items.push(build_item(
&record.assessment.id,
&placement.item,
item,
points,
opts,
));
}
if !problems.is_empty() {
return Err(Error::Invalid(problems));
}
let metadata = vec![
metadata_field("cc_maxattempts", &opts.attempts.to_string()),
metadata_field("cc_quiz_scoring_policy", opts.scoring_policy.as_str()),
metadata_field(
"cc_shuffle_answers",
if opts.shuffle_in_canvas {
"true"
} else {
"false"
},
),
];
let title = if record.forms.len() > 1 {
format!("{} (form {})", record.assessment.title, opts.form.id)
} else {
record.assessment.title.clone()
};
let assessment = Node::new("assessment")
.attr("ident", assessment_id.clone())
.attr("title", title.clone())
.child(Node::new("qtimetadata").children(metadata))
.child(
Node::new("section")
.attr("ident", "root_section")
.children(items),
);
let root = Node::new("questestinterop")
.attr("xmlns", QTI_NS)
.attr("xmlns:xsi", XSI_NS)
.attr("xsi:schemaLocation", QTI_SCHEMA)
.child(assessment);
let quiz_filename = format!("{}.xml", slug_filename(&record.assessment.id));
let manifest = build_manifest(
&quiz_filename,
&assessment_id,
&title,
&record.assessment.id,
);
Ok(Package {
quiz_filename,
quiz_xml: root.document(),
manifest_xml: manifest.document(),
})
}
/// Builds one `<item>` element.
///
/// # 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_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &QtiOptions) -> Node {
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();
// Option identifiers are numeric, mirroring Canvas's own exports, and are
// derived from the item id so they survive regeneration.
let opt_ids: Vec<String> = ordered
.iter()
.map(|o| short_id(&format!("{assessment_id}/{uid}/{}", o.id)))
.collect();
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("original_answer_ids", &opt_ids.join(",")),
metadata_field("assessment_question_identifierref", &qti_id(uid)),
]));
let cardinality = if item.format == Format::MultipleResponse {
"Multiple"
} else {
"Single"
};
let labels: Vec<Node> = ordered
.iter()
.zip(opt_ids.iter())
.map(|(o, id)| {
Node::new("response_label")
.attr("ident", id.clone())
.child(mattext(&markup::to_html(&o.text)))
})
.collect();
let presentation = Node::new("presentation")
.child(mattext(&format!(
"<div>{}</div>",
markup::to_html(&item.stem)
)))
.child(
Node::new("response_lid")
.attr("ident", "response1")
.attr("rcardinality", cardinality)
.child(Node::new("render_choice").children(labels)),
);
// --- response processing ---
let mut resprocessing = Node::new("resprocessing").child(
Node::new("outcomes").child(
Node::new("decvar")
.attr("maxvalue", "100")
.attr("minvalue", "0")
.attr("varname", "SCORE")
.attr("vartype", "Decimal"),
),
);
// A pass-through condition per option, so choosing anything triggers its
// feedback. `continue="Yes"` is what allows scoring to be evaluated after.
if opts.include_feedback {
for (o, id) in ordered.iter().zip(opt_ids.iter()) {
if o.student_text().is_none() {
continue;
}
resprocessing = resprocessing.child(
Node::new("respcondition")
.attr("continue", "Yes")
.child(
Node::new("conditionvar").child(
Node::new("varequal")
.attr("respident", "response1")
.text(id.clone()),
),
)
.child(
Node::new("displayfeedback")
.attr("feedbacktype", "Response")
.attr("linkrefid", format!("{id}_fb")),
),
);
}
}
let correct: Vec<&String> = ordered
.iter()
.zip(opt_ids.iter())
.filter(|(o, _)| o.correct)
.map(|(_, id)| id)
.collect();
let incorrect: Vec<&String> = ordered
.iter()
.zip(opt_ids.iter())
.filter(|(o, _)| !o.correct)
.map(|(_, id)| id)
.collect();
let condition = if item.format == Format::MultipleResponse {
// Full credit only for the exact set: every keyed option chosen and no
// unkeyed one. Without the negations, checking every box scores 100.
let mut and = Node::new("and");
for id in &correct {
and = and.child(
Node::new("varequal")
.attr("respident", "response1")
.text((*id).clone()),
);
}
for id in &incorrect {
and = and.child(
Node::new("not").child(
Node::new("varequal")
.attr("respident", "response1")
.text((*id).clone()),
),
);
}
Node::new("conditionvar").child(and)
} else {
Node::new("conditionvar").child(
Node::new("varequal")
.attr("respident", "response1")
.text(correct.first().map(|s| (*s).clone()).unwrap_or_default()),
)
};
resprocessing = resprocessing.child(
Node::new("respcondition")
.attr("continue", "No")
.child(condition)
.child(
Node::new("setvar")
.attr("action", "Set")
.attr("varname", "SCORE")
.text("100"),
),
);
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 opts.include_feedback {
for (o, id) in ordered.iter().zip(opt_ids.iter()) {
if let Some(text) = o.student_text() {
node = node.child(
Node::new("itemfeedback")
.attr("ident", format!("{id}_fb"))
.child(
Node::new("flow_mat")
.child(mattext(&format!("<div>{}</div>", markup::to_html(text)))),
),
);
}
}
}
node
}
/// A `<material><mattext texttype="text/html">` pair.
///
/// # Arguments
///
/// * `html` - the HTML fragment, which is escaped on the way in.
///
/// # Returns
///
/// The element.
fn mattext(html: &str) -> Node {
Node::new("material").child(
Node::new("mattext")
.attr("texttype", "text/html")
.text(html),
)
}
/// A `<qtimetadatafield>` pair.
fn metadata_field(label: &str, entry: &str) -> Node {
Node::new("qtimetadatafield")
.child(Node::new("fieldlabel").text(label))
.child(Node::new("fieldentry").text(entry))
}
/// Builds the IMS content package manifest.
///
/// # Arguments
///
/// * `quiz_filename` - the quiz XML file name.
/// * `assessment_id` - the assessment identifier, reused as the resource id.
/// * `title` - the human title.
/// * `salt` - salts the manifest identifier.
///
/// # Returns
///
/// The manifest element.
fn build_manifest(quiz_filename: &str, assessment_id: &str, title: &str, salt: &str) -> Node {
let lom = Node::new("imsmd:lom").child(
Node::new("imsmd:general").child(
Node::new("imsmd:title").child(
Node::new("imsmd:langstring")
.attr("xml:lang", "en-US")
.text(title),
),
),
);
Node::new("manifest")
.attr("identifier", format!("man{}", qti_id(salt)))
.attr("xmlns", IMSCP_NS)
.attr("xmlns:imsmd", IMSMD_NS)
.attr("xmlns:xsi", XSI_NS)
.attr("xsi:schemaLocation", IMSCP_SCHEMA)
.child(
Node::new("metadata")
.child(Node::new("schema").text("IMS Content"))
.child(Node::new("schemaversion").text("1.1.3"))
.child(lom),
)
.child(
Node::new("organizations").attr("default", "root").child(
Node::new("organization")
.attr("identifier", "root")
.attr("structure", "rooted"),
),
)
.child(
Node::new("resources").child(
Node::new("resource")
.attr("identifier", assessment_id)
.attr("type", "imsqti_xmlv1p2")
.attr("href", quiz_filename)
.child(Node::new("file").attr("href", quiz_filename)),
),
)
}
/// A deterministic QTI identifier: `g` followed by 32 hex characters.
///
/// # Arguments
///
/// * `key` - the stable string to derive from.
///
/// # Returns
///
/// The identifier.
fn qti_id(key: &str) -> String {
let digest = hex(&sha256(key.as_bytes()));
format!("g{}", &digest[..32])
}
/// A deterministic short numeric identifier, as Canvas uses for answers.
///
/// # Arguments
///
/// * `key` - the stable string to derive from.
///
/// # Returns
///
/// A four-digit numeric string.
fn short_id(key: &str) -> String {
let d = sha256(key.as_bytes());
let n = u32::from_be_bytes([d[0], d[1], d[2], d[3]]);
// 1000..9999 keeps the width fixed, which some Canvas importers prefer.
format!("{}", 1000 + (n % 9000))
}
/// Makes a file-name-safe slug.
fn slug_filename(s: &str) -> String {
let mut out = String::new();
for ch in s.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() {
"quiz".to_string()
} else {
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escapes_html_once_inside_mattext() {
let n = mattext("<p>a &rarr; b</p>");
let xml = n.render(0);
// The HTML is character data, so its markup is escaped.
assert!(xml.contains("&lt;p&gt;a &amp;rarr; b&lt;/p&gt;"), "{xml}");
assert!(!xml.contains("<p>"));
}
#[test]
fn attributes_are_escaped() {
let xml = Node::new("item")
.attr("title", "a \"quoted\" & <angled>")
.render(0);
assert!(xml.contains("&quot;quoted&quot;"));
assert!(xml.contains("&amp;"));
assert!(!xml.contains("<angled>"));
}
#[test]
fn ids_are_deterministic_and_well_formed() {
assert_eq!(qti_id("a"), qti_id("a"));
assert_ne!(qti_id("a"), qti_id("b"));
let id = qti_id("exam-4/assessment");
assert_eq!(id.len(), 33);
assert!(id.starts_with('g'));
assert!(id[1..].chars().all(|c| c.is_ascii_hexdigit()));
let s = short_id("x");
assert_eq!(s.len(), 4);
assert!(s.parse::<u32>().unwrap() >= 1000);
}
#[test]
fn empty_element_renders_self_closing() {
assert_eq!(
Node::new("file").attr("href", "q.xml").render(0),
"<file href=\"q.xml\"/>\n"
);
}
#[test]
fn document_has_a_declaration() {
let doc = Node::new("root").document();
assert!(doc.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"));
}
#[test]
fn manifest_points_at_the_quiz_file() {
let m = build_manifest("exam-4.xml", "gabc", "Exam 4", "exam-4").render(0);
assert!(m.contains("imsqti_xmlv1p2"));
assert!(m.contains("href=\"exam-4.xml\""));
assert!(m.contains("Exam 4"));
}
#[test]
fn slug_filename_is_safe() {
assert_eq!(slug_filename("exam-4 2026s"), "exam-4_2026s");
assert_eq!(slug_filename(""), "quiz");
}
}
+1084
View File
File diff suppressed because it is too large Load Diff
+518
View File
@@ -0,0 +1,518 @@
//! Rendering a printed exam with Typst.
//!
//! Typst rather than LaTeX because the toolchain is one binary with no package
//! manager, the error messages point at a line, and the compile is fast enough to
//! iterate on. `pixi run -e docs typst compile` turns the output of this module
//! into a PDF.
//!
//! The emitted document is deliberately plain and self-contained: no imports, no
//! template packages, nothing that can break because a package version moved. It
//! is also meant to be edited. A generated exam is a starting point, and the
//! output is formatted so that a human can reasonably open it and adjust spacing
//! before printing.
//!
//! Every export takes a form, so form B's answer key is generated from the same
//! permutation that produced form B's question paper. Keeping the key and the
//! paper in one code path is the only way to be sure they agree — a mismatch is
//! discovered by twenty-five students at once.
use crate::assessment::{AssessmentFile, Form};
use crate::catalog::Catalog;
use crate::course::CourseFile;
use crate::error::{Error, Result};
use crate::markup;
use crate::select;
/// Options for a printed exam.
#[derive(Debug, Clone)]
pub struct Options {
/// Which form to render.
pub form: Form,
/// Whether to leave a name and student-id block at the top.
pub name_block: bool,
/// Whether to show the points each question is worth.
pub show_points: bool,
/// Whether to start each question on a new page. Occasionally worth it for an
/// exam with long stimuli.
pub page_per_item: bool,
/// Paper size, as Typst names it.
pub paper: String,
/// Base font size.
pub font_size: String,
}
impl Default for Options {
fn default() -> Options {
Options {
form: Form {
id: "A".to_string(),
seed: 0,
shuffle_items: false,
shuffle_options: false,
},
name_block: true,
show_points: true,
page_per_item: false,
paper: "us-letter".to_string(),
font_size: "11pt".to_string(),
}
}
}
/// Renders the question paper.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `opts` - rendering options.
///
/// # Returns
///
/// A complete Typst document.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when a placement references a missing item.
pub fn exam(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<String> {
let course = &catalog.course;
let mut out = String::new();
out.push_str(&preamble(course, record, opts));
if opts.name_block {
out.push_str(
"#block(above: 1em, below: 1.5em)[\n \
#grid(columns: (auto, 1fr, auto, 1fr), gutter: 0.6em,\n \
[*Name*], [#box(width: 100%, repeat[.])],\n \
[*Student ID*], [#box(width: 100%, repeat[.])],\n )\n]\n\n",
);
}
if let Some(instructions) = &record.assessment.instructions {
out.push_str(&format!(
"#block(fill: luma(245), inset: 8pt, radius: 3pt, width: 100%)[\n {}\n]\n\n",
markup::to_typst(instructions)
));
}
let default_points = course.policy.points_per_item;
let mut printed = 0usize;
for placement in select::layout(record, &opts.form) {
if placement.dropped {
continue;
}
let entry = catalog.require(&placement.item)?;
let item = &entry.item;
printed += 1;
if opts.page_per_item && printed > 1 {
out.push_str("#pagebreak()\n\n");
}
// A stimulus shared by several items is printed with each of them. That
// repeats material, but a student should never have to flip pages to find
// the passage a question refers to.
if let Some(id) = &item.stimulus {
if let Some(stimulus) = course.stimuli.get(id) {
out.push_str(&format!(
"#block(stroke: 0.5pt + luma(180), inset: 8pt, radius: 3pt, width: 100%)[\n \
{}\n]\n",
markup::to_typst(&stimulus.body)
));
if let Some(caption) = &stimulus.caption {
out.push_str(&format!(
"#block(above: 0.3em)[#text(size: 0.85em, style: \"italic\")[{}]]\n",
markup::to_typst(caption)
));
}
}
}
let points = placement
.points
.unwrap_or_else(|| item.points(default_points));
let label = if placement.bonus {
if opts.show_points {
format!(
" #text(fill: rgb(\"#666666\"))[(bonus, {})]",
plural_points(points)
)
} else {
" #text(fill: rgb(\"#666666\"))[(bonus)]".to_string()
}
} else if opts.show_points {
format!(
" #text(fill: rgb(\"#666666\"))[({})]",
plural_points(points)
)
} else {
String::new()
};
// The question number is the recorded one, not the printed position, so a
// scanned answer sheet still joins to the assessment record.
out.push_str(&format!(
"#block(above: 1.2em, below: 0.5em)[*{}.*{label} {}]\n",
placement.number,
markup::to_typst(&item.stem)
));
if item.is_multi_key() {
out.push_str(
"#block(below: 0.4em)[#text(size: 0.9em, style: \"italic\")[Select all that \
apply.]]\n",
);
}
let order = select::option_order(&opts.form, &placement.item, item.options.len());
out.push_str("#block(inset: (left: 1.2em))[\n");
for (position, source_index) in order.iter().enumerate() {
let choice = &item.options[*source_index];
// Options are relabeled by printed position, so a shuffled form still
// reads A, B, C, D.
let letter = (b'A' + position as u8) as char;
out.push_str(&format!(
" #grid(columns: (1.4em, 1fr), gutter: 0.2em)[{letter}.][{}]\n",
markup::to_typst(&choice.text)
));
}
out.push_str("]\n\n");
}
if printed == 0 {
return Err(Error::Invalid(vec![
"this assessment has no printable items; every placement is marked dropped".to_string(),
]));
}
Ok(out)
}
/// Renders the answer key.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `opts` - rendering options, whose form determines the letters.
///
/// # Returns
///
/// A complete Typst document.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when a placement references a missing item.
pub fn answer_key(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<String> {
let mut out = String::new();
out.push_str(&format!(
"#set page(paper: \"{}\", margin: 2cm)\n#set text(size: 10pt)\n\n\
= {} answer key (form {})\n\n",
opts.paper,
escape(&record.assessment.title),
opts.form.id
));
out.push_str(
"#text(size: 0.9em, style: \"italic\")[Letters below are the letters *as printed on this \
form*. Do not use this key on another form.]\n\n",
);
out.push_str(
"#table(\n columns: (auto, auto, auto, 1fr),\n align: (right, center, center, left),\n \
table.header([*\\#*], [*Key*], [*Level*], [*Objectives*]),\n",
);
for placement in select::layout(record, &opts.form) {
if placement.dropped {
continue;
}
let entry = catalog.require(&placement.item)?;
let item = &entry.item;
let order = select::option_order(&opts.form, &placement.item, item.options.len());
// Map the source option index to the letter it was printed as.
let mut printed_letters = Vec::new();
for (position, source_index) in order.iter().enumerate() {
if item.options[*source_index].correct {
printed_letters.push(((b'A' + position as u8) as char).to_string());
}
}
let objectives = if placement.learning_objectives.is_empty() {
item.learning_objectives.join(", ")
} else {
placement.learning_objectives.join(", ")
};
out.push_str(&format!(
" [{}], [*{}*], [{}], [{}],\n",
placement.number,
printed_letters.join(""),
placement
.level
.map(|l| l.code().to_string())
.unwrap_or_else(|| "-".into()),
escape(&objectives)
));
}
out.push_str(")\n\n");
// Partial credit decisions belong on the key, where the grader will see them.
let overrides: Vec<String> = record
.items
.iter()
.filter(|p| !p.credit_overrides.is_empty())
.map(|p| {
let list: Vec<String> = p
.credit_overrides
.iter()
.map(|(letter, credit)| format!("{letter} = {:.0}%", credit * 100.0))
.collect();
format!("Question {}: {}", p.number, list.join(", "))
})
.collect();
if !overrides.is_empty() {
out.push_str("== Partial credit\n\n");
for line in overrides {
out.push_str(&format!("- {}\n", escape(&line)));
}
out.push('\n');
}
let dropped: Vec<String> = record
.items
.iter()
.filter(|p| p.dropped)
.map(|p| p.number.to_string())
.collect();
if !dropped.is_empty() {
out.push_str(&format!(
"== Dropped\n\nQuestion(s) {} were dropped and are not printed.\n\n",
dropped.join(", ")
));
}
Ok(out)
}
/// Renders a bubble sheet matching the form.
///
/// # Arguments
///
/// * `catalog` - the loaded course, for option counts.
/// * `record` - the assessment record.
/// * `opts` - rendering options.
///
/// # Returns
///
/// A complete Typst document.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when a placement references a missing item.
pub fn bubble_sheet(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<String> {
let mut out = format!(
"#set page(paper: \"{}\", margin: 1.5cm)\n#set text(size: 10pt)\n\n\
= {} answer sheet (form {})\n\n\
#grid(columns: (auto, 1fr, auto, 1fr), gutter: 0.6em,\n \
[*Name*], [#box(width: 100%, repeat[.])],\n \
[*Student ID*], [#box(width: 100%, repeat[.])],\n)\n\n\
#v(1em)\n",
opts.paper,
escape(&record.assessment.title),
opts.form.id
);
out.push_str("#columns(2)[\n");
for placement in select::layout(record, &opts.form) {
if placement.dropped {
continue;
}
let entry = catalog.require(&placement.item)?;
let count = entry.item.options.len();
let bubbles: Vec<String> = (0..count)
.map(|i| {
let letter = (b'A' + i as u8) as char;
format!("#circle(radius: 0.42em, stroke: 0.5pt)[#align(center + horizon)[#text(size: 0.7em)[{letter}]]]")
})
.collect();
out.push_str(&format!(
" #block(below: 0.45em)[#box(width: 2em)[{}.] {}]\n",
placement.number,
bubbles.join(" ")
));
}
out.push_str("]\n");
Ok(out)
}
/// Builds the document preamble.
///
/// # Arguments
///
/// * `course` - the course.
/// * `record` - the assessment record.
/// * `opts` - rendering options.
///
/// # Returns
///
/// Typst setup and a title block.
fn preamble(course: &CourseFile, record: &AssessmentFile, opts: &Options) -> String {
let form_note = if record.forms.len() > 1 {
format!(" · Form {}", opts.form.id)
} else {
String::new()
};
let date = record
.assessment
.date
.map(|d| d.to_string())
.unwrap_or_default();
let minutes = record
.assessment
.minutes_allowed
.map(|m| format!(" · {m:.0} minutes"))
.unwrap_or_default();
format!(
"#set page(\n paper: \"{paper}\",\n margin: 2cm,\n \
header: [#text(size: 0.85em)[{code} · {title}{form_note}]],\n \
footer: context [#text(size: 0.85em)[Page #counter(page).display() of \
#counter(page).final().first()]],\n)\n\
#set text(size: {size})\n\
#set par(justify: false, leading: 0.65em)\n\n\
#align(center)[\n #text(size: 1.4em, weight: \"bold\")[{title}]\n \\\n \
#text(size: 0.95em)[{code} {course_title} · {term}]\n \\\n \
#text(size: 0.9em)[{date}{minutes}]\n]\n\n",
paper = opts.paper,
size = opts.font_size,
code = escape(&course.course.code),
course_title = escape(&course.course.title),
term = escape(
record
.assessment
.term
.as_deref()
.unwrap_or(&course.course.term)
),
title = escape(&record.assessment.title),
form_note = form_note,
date = date,
minutes = minutes,
)
}
/// Formats a point value with the right plural.
fn plural_points(points: f64) -> String {
if (points - 1.0).abs() < 1e-9 {
"1 point".to_string()
} else if (points.fract()).abs() < 1e-9 {
format!("{points:.0} points")
} else {
format!("{points} points")
}
}
/// Escapes text for Typst content mode.
fn escape(s: &str) -> String {
markup::to_typst(s)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assessment::Placement;
#[test]
fn point_labels_are_pluralized() {
assert_eq!(plural_points(1.0), "1 point");
assert_eq!(plural_points(2.0), "2 points");
assert_eq!(plural_points(1.5), "1.5 points");
}
#[test]
fn escaping_protects_typst_syntax() {
assert_eq!(escape("email me @ home"), "email me \\@ home");
assert_eq!(escape("a < b"), "a \\< b");
}
#[test]
fn the_key_reports_letters_as_printed() {
// Shuffling must relabel the key: if the correct option moves to the third
// printed position, the key says C.
let form = Form {
id: "B".into(),
seed: 99,
shuffle_items: false,
shuffle_options: true,
};
let order = select::option_order(&form, "bank::q-1", 4);
let correct_source = 0usize;
let printed_position = order.iter().position(|i| *i == correct_source).unwrap();
let letter = (b'A' + printed_position as u8) as char;
assert!(('A'..='D').contains(&letter));
// And it is reproducible.
let again = select::option_order(&form, "bank::q-1", 4);
assert_eq!(order, again);
}
#[test]
fn dropped_items_are_not_printed() {
let record = AssessmentFile {
schema_version: "1.0".into(),
assessment: crate::assessment::Assessment {
id: "e1".into(),
title: "Exam 1".into(),
term: None,
kind: crate::assessment::Kind::Exam,
date: None,
platform: crate::assessment::Platform::Paper,
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: "b::q-1".into(),
version: None,
fingerprint: None,
points: None,
bonus: false,
key: vec!["A".into()],
level: None,
learning_objectives: Vec::new(),
credit_overrides: Default::default(),
dropped: true,
},
Placement {
number: 2,
item: "b::q-2".into(),
version: None,
fingerprint: None,
points: None,
bonus: false,
key: vec!["B".into()],
level: None,
learning_objectives: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
},
],
};
let printable: Vec<u32> = select::layout(&record, &Options::default().form)
.into_iter()
.filter(|p| !p.dropped)
.map(|p| p.number)
.collect();
assert_eq!(printable, vec![2]);
}
}
+106
View File
@@ -0,0 +1,106 @@
//! # coursebank
//!
//! A tool for running the assessment side of a course as version-controlled data.
//!
//! The premise is that the artifacts you already produce (questions, exams,
//! grading exports) are worth treating as a dataset rather than as a pile of
//! documents. Once they are, several things you cannot otherwise do become
//! routine: knowing which questions actually discriminate, catching a poorly worded
//! item from the pattern of who chose which distractor, telling a student which
//! misconception their specific wrong answer indicates, and never accidentally
//! reusing the same question three terms in a row.
//!
//! ## The four kinds of file
//!
//! | File | Holds | Written by |
//! |:--|:--|:--|
//! | `course.yaml` | identity, policy, objectives, lectures | you |
//! | `banks/*.yaml` | items, with design intent and pooled statistics | you, then `calibrate` |
//! | `assessments/*.yaml` | what was given, to whom, when | `assemble`, then you |
//! | `data/*.parquet` | one row per student per item | `ingest` |
//!
//! Three of the four are hand-editable YAML meant to be reviewed in a pull request.
//! Only the response data is machine-only, and it is stored in an open columnar
//! format so pandas, polars, R, and DuckDB can all read it without this tool.
//!
//! ## The loop
//!
//! ```text
//! author items ──▶ validate ──▶ lint ──▶ assemble ──▶ export ──┐
//! ▲ │
//! │ administer
//! │ │
//! calibrate ◀── analyze ◀── ingest ◀───────────────────────────┘
//! │
//! └──▶ report (students and cohort)
//! ```
//!
//! The arrow back from `calibrate` to authoring is the point of the whole design.
//! Statistics written onto the item are there the next time you consider using it,
//! and they accumulate across terms: twenty-four students tells you very little,
//! but ninety-six across four terms tells you something real.
//!
//! ## Design commitments
//!
//! Assessment records are the single source of truth for reuse history. There
//! is no separate ledger file, because a ledger duplicates what the records must
//! already get right and then drifts from it. [`assessment::History`] derives usage
//! by scanning the records.
//!
//! **Fingerprints cover only what a student saw.** Retag an item's metadata and its
//! pooled statistics stay valid; reword the stem and they are marked stale. See
//! [`item::Item::fingerprint`].
//!
//! **Validation reports everything at once.** Fixing one typo per run is not a
//! workflow. [`error::Error::Invalid`] carries a list.
//!
//! **Validation and linting are separate.** [`bank::BankFile::validate`] enforces
//! what must be true; [`lint`] advises on what is usually a mistake, and every rule
//! has a code you can silence.
//!
//! **Small samples are labelled as such.** Every statistic computed from a class of
//! twenty-five is reported with the caveat it deserves rather than three decimal
//! places of false precision.
//!
//! ## Dependency posture
//!
//! Deliberately small: serde, a YAML parser, clap, thiserror, and csv, plus arrow
//! and parquet behind a default-on feature that can be switched off. Dates, PRNG,
//! hashing, ZIP writing, and the psychometrics are implemented here rather than
//! pulled in — see [`date`], [`rng`], [`hash`], [`zipfile`], [`irt`]. For a tool
//! whose job is to still open a course repository in five years, that tradeoff
//! favours fewer moving parts.
#![warn(missing_docs)]
#![forbid(unsafe_code)]
pub mod analysis;
pub mod authoring;
pub mod data;
pub mod error;
pub mod export;
pub mod model;
pub mod util;
pub use util::{date, hash, markup, rng, yaml, zipfile};
pub use model::{assessment, bank, catalog, course, item, taxonomy};
pub use authoring::{jsonschema, lint, select};
#[cfg(feature = "parquet")]
pub use data::store_parquet;
pub use data::{canvas, gradescope, responses, store};
pub use analysis::{calibrate, classical, irt, students};
pub use export::{qti, report, typst};
pub use catalog::Catalog;
pub use course::{CourseFile, Layout, SCHEMA_VERSION};
pub use error::{Error, Result};
pub use item::Item;
pub use taxonomy::{CognitiveProcess, ErrorType, Flag, Format, Level, Status};
/// Version of package.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
+2043
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
//! The data model: the four kinds of file, and the taxonomy they are written against.
//!
//! These modules define what a course is as far as this tool is concerned, and
//! they own all validation of it. Everything else in the crate reads these types.
//!
//! The dependency order runs downward and never back up:
//!
//! ```text
//! taxonomy levels, cognitive processes, error types, status, flags
//! │
//! course course.yaml: identity, policy, objectives, lectures, stimuli
//! │
//! item one question: stem, options, design intent, calibration
//! │
//! bank a file of items, plus defaults applied on load
//! │
//! catalog every bank resolved against the course; coverage and gaps
//! │
//! assessment what was given, to whom, when — and the reuse history derived
//! by scanning those records rather than kept in a ledger
//! ```
pub mod assessment;
pub mod bank;
pub mod catalog;
pub mod course;
pub mod item;
pub mod taxonomy;
+824
View File
@@ -0,0 +1,824 @@
//! The assessment record: what you actually asked, in what order, on what date.
//!
//! This file is the hinge of the whole tool, and it is worth being explicit about
//! why it exists as a separate artifact.
//!
//! Grading exports do not know about your item bank. Gradescope gives you
//! `14.csv`; Canvas gives you a column header. Both identify questions by
//! *position on a form*. An item bank identifies questions by stable id. The
//! assessment record is the only place those two namespaces meet, and without it
//! there is no way to say that question 14 of Exam 4 was
//! `docking::q-scoring-003` at version 2.
//!
//! Recording it also makes usage history free. Rather than maintaining a separate
//! ledger that can drift out of sync with reality, `coursebank usage` scans the
//! assessment records: an item was used exactly when it appears on a record. The
//! records are the source of truth, and they are small, readable, and diffable.
//!
//! Each placement stores the resolved key and content fingerprint *as used*. A
//! year later, when the item has been reworded twice, you can still see what the
//! students in front of you were asked.
use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::catalog::Catalog;
use crate::course::SCHEMA_VERSION;
use crate::date::Date;
use crate::error::Result;
use crate::taxonomy::Level;
use crate::yaml;
/// A whole assessment record file.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AssessmentFile {
/// Schema version this file targets.
#[serde(
default = "default_version",
deserialize_with = "yaml::flexible_string"
)]
pub schema_version: String,
/// Identity and administration details.
pub assessment: Assessment,
/// The blueprint this was assembled against, when it was assembled by the
/// tool. Keeping it lets you check the form you shipped against the design
/// you intended.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blueprint: Option<Blueprint>,
/// Alternate forms, each a permutation of the same items.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub forms: Vec<Form>,
/// The items, in printed order.
#[serde(default)]
pub items: Vec<Placement>,
}
/// Assessment identity and administration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Assessment {
/// Stable id, e.g. `exam-4-2026s`. Response tables carry this.
pub id: String,
/// Human title as printed, e.g. `Exam 4`.
pub title: String,
/// The term this administration belongs to. Items outlive terms, so the term
/// lives here rather than on the item.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub term: Option<String>,
/// What kind of assessment this is.
#[serde(default = "default_kind")]
pub kind: Kind,
/// When it was administered.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub date: Option<Date>,
/// Where it was administered.
#[serde(default = "default_platform")]
pub platform: Platform,
/// Time allowed, in minutes. Compared against the summed expected time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub minutes_allowed: Option<f64>,
/// Attempts allowed; `-1` means unlimited. Only meaningful online.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attempts: Option<i64>,
/// Whether the platform should shuffle options.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shuffle: Option<bool>,
/// How repeated attempts are scored.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scoring_policy: Option<ScoringPolicy>,
/// Instructions printed at the top of a paper form.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
/// Notes to yourself about this administration.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
/// What kind of assessment a record describes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
/// A summative in-term exam.
Exam,
/// A short graded check.
Quiz,
/// Graded work done outside class.
Homework,
/// Ungraded practice.
Practice,
/// A cumulative final.
Final,
}
impl Kind {
/// The token used in YAML and in file names.
pub fn as_str(self) -> &'static str {
match self {
Kind::Exam => "exam",
Kind::Quiz => "quiz",
Kind::Homework => "homework",
Kind::Practice => "practice",
Kind::Final => "final",
}
}
/// Whether results from this kind should feed item calibration.
///
/// Practice work is excluded by default: it is usually untimed, open book,
/// and attempted more than once, so pooling it with exam data would bias
/// every difficulty estimate downward.
pub fn counts_for_calibration(self) -> bool {
!matches!(self, Kind::Practice)
}
}
/// Where an assessment was administered, which determines the export format and
/// the shape of the grading data that comes back.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Platform {
/// Printed and scanned, graded in Gradescope.
Paper,
/// A Canvas quiz.
Canvas,
/// Delivered on paper but graded elsewhere.
Other,
}
/// How repeated attempts are scored.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScoringPolicy {
/// Keep the best attempt.
KeepHighest,
/// Keep the most recent attempt.
KeepLatest,
}
impl ScoringPolicy {
/// The token Canvas expects in a QTI package.
pub fn as_str(self) -> &'static str {
match self {
ScoringPolicy::KeepHighest => "keep_highest",
ScoringPolicy::KeepLatest => "keep_latest",
}
}
}
/// The design an assessment was assembled against.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Blueprint {
/// How many scored items to draw at each level.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub level_counts: BTreeMap<Level, usize>,
/// How many bonus items to draw at each level.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub bonus_counts: BTreeMap<Level, usize>,
/// Objectives that must appear, with a minimum item count each.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub objective_minimums: BTreeMap<String, usize>,
/// Restrict the draw to these lectures.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lectures: Vec<String>,
/// Restrict the draw to these topics.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub topics: Vec<String>,
/// Restrict the draw to these banks.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub banks: Vec<String>,
/// The most items any one bank may contribute, so a form is not dominated by
/// whichever topic you happened to write the most questions about.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_per_bank: Option<usize>,
/// Do not reuse an item used within this many days.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cooldown_days: Option<i64>,
/// The seed used, so the draw can be reproduced exactly.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
}
impl Blueprint {
/// Total scored items requested.
pub fn scored_total(&self) -> usize {
self.level_counts.values().sum()
}
/// Total bonus items requested.
pub fn bonus_total(&self) -> usize {
self.bonus_counts.values().sum()
}
}
/// One alternate form of the same assessment.
///
/// A form does not change which items appear, only their order and the order of
/// their options, so every form measures the same thing and one answer key
/// generator serves them all.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Form {
/// Form label, e.g. `A`.
pub id: String,
/// Seed for the permutation. Reproducing a printed form requires it.
pub seed: u64,
/// Whether item order is permuted.
#[serde(default = "yes")]
pub shuffle_items: bool,
/// Whether option order is permuted within each item.
#[serde(default = "yes")]
pub shuffle_options: bool,
}
/// One item as placed on the form.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Placement {
/// Printed question number. This is the join key to grading exports, which
/// is the entire reason this record exists.
pub number: u32,
/// The item's global id, `bank::item`.
pub item: String,
/// The item version used.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
/// The content fingerprint as used, so later edits are detectable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>,
/// Points as administered.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub points: Option<f64>,
/// Whether it was scored as bonus here.
#[serde(default, skip_serializing_if = "is_false")]
pub bonus: bool,
/// The keyed letters as administered.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub key: Vec<String>,
/// The level as administered, denormalized so a record reads standalone.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<Level>,
/// Objectives as administered, denormalized for the same reason.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec<String>,
/// Credit awarded to non-keyed options after the fact, keyed by letter.
///
/// When item analysis or a student challenge leads you to credit a
/// distractor, recording it here keeps the rescoring decision with the
/// administration it applies to instead of quietly editing the item.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub credit_overrides: BTreeMap<String, f64>,
/// Set when an item was dropped from scoring after administration.
#[serde(default, skip_serializing_if = "is_false")]
pub dropped: bool,
}
impl AssessmentFile {
/// Loads an assessment record.
///
/// # Arguments
///
/// * `path` - the YAML file.
///
/// # Returns
///
/// The parsed record.
///
/// # Errors
///
/// Returns a load error.
pub fn load(path: &Path) -> Result<AssessmentFile> {
yaml::read(path)
}
/// Writes the record back out.
///
/// # Arguments
///
/// * `path` - the destination.
///
/// # Errors
///
/// Returns [`crate::error::Error::Io`] on a write failure.
pub fn save(&self, path: &Path) -> Result<()> {
yaml::write(path, self)
}
/// Loads every assessment record in a directory, sorted by date then id.
///
/// # Arguments
///
/// * `dir` - the assessments directory.
///
/// # Returns
///
/// The records, empty when the directory does not exist.
///
/// # Errors
///
/// Propagates load errors.
pub fn load_all(dir: &Path) -> Result<Vec<AssessmentFile>> {
let mut out = Vec::new();
for path in yaml::list_yaml(dir)? {
out.push(AssessmentFile::load(&path)?);
}
out.sort_by(|a, b| {
a.assessment
.date
.cmp(&b.assessment.date)
.then(a.assessment.id.cmp(&b.assessment.id))
});
Ok(out)
}
/// The placement at a printed question number.
///
/// # Arguments
///
/// * `number` - the printed number.
///
/// # Returns
///
/// The placement, or `None`.
pub fn placement(&self, number: u32) -> Option<&Placement> {
self.items.iter().find(|p| p.number == number)
}
/// Total scored points, excluding bonus and dropped items.
///
/// # Arguments
///
/// * `default_points` - the course policy default.
///
/// # Returns
///
/// The total.
pub fn total_points(&self, default_points: f64) -> f64 {
self.items
.iter()
.filter(|p| !p.bonus && !p.dropped)
.map(|p| p.points.unwrap_or(default_points))
.sum()
}
/// Counts of scored placements by level.
///
/// # Returns
///
/// A map from level to count.
pub fn level_counts(&self) -> BTreeMap<Level, usize> {
let mut out: BTreeMap<Level, usize> = BTreeMap::new();
for p in self.items.iter().filter(|p| !p.bonus) {
if let Some(l) = p.level {
*out.entry(l).or_insert(0) += 1;
}
}
out
}
/// Checks the record for internal problems and for drift from the bank.
///
/// The drift check is the valuable part: if an item was reworded after this
/// assessment was given, the fingerprints disagree, and any analysis that
/// pools this administration with a later one is comparing two questions.
///
/// # Arguments
///
/// * `catalog` - the loaded course, for resolving item references.
///
/// # Returns
///
/// Every problem found.
pub fn validate(&self, catalog: Option<&Catalog>) -> Vec<String> {
let mut issues = Vec::new();
if self.assessment.id.trim().is_empty() {
issues.push("assessment.id is empty".into());
}
if self.items.is_empty() {
issues.push("the record lists no items".into());
}
let mut numbers: BTreeMap<u32, usize> = BTreeMap::new();
for p in &self.items {
*numbers.entry(p.number).or_insert(0) += 1;
}
for (n, count) in &numbers {
if *count > 1 {
issues.push(format!("question number {n} is used {count} times"));
}
}
// Gaps are legal but almost always a mistake, since grading exports are
// numbered contiguously.
let mut sorted: Vec<u32> = numbers.keys().copied().collect();
sorted.sort_unstable();
for (i, n) in sorted.iter().enumerate() {
let expected = i as u32 + 1;
if *n != expected {
issues.push(format!(
"question numbers are not contiguous from 1; expected {expected}, found {n}"
));
break;
}
}
let mut seen_items: BTreeMap<&str, u32> = BTreeMap::new();
for p in &self.items {
if let Some(first) = seen_items.get(p.item.as_str()) {
issues.push(format!(
"item `{}` appears twice, at questions {first} and {}",
p.item, p.number
));
} else {
seen_items.insert(p.item.as_str(), p.number);
}
for (letter, credit) in &p.credit_overrides {
if !(0.0..=1.0).contains(credit) {
issues.push(format!(
"question {}: credit override for `{letter}` must be in [0, 1], got {credit}",
p.number
));
}
}
}
let mut form_ids: Vec<&str> = Vec::new();
for form in &self.forms {
if form_ids.contains(&form.id.as_str()) {
issues.push(format!("duplicate form id `{}`", form.id));
}
form_ids.push(&form.id);
}
if let Some(cat) = catalog {
for p in &self.items {
match cat.get(&p.item) {
None => {
issues.push(format!("question {}: unknown item `{}`", p.number, p.item))
}
Some(entry) => {
if let Some(fp) = &p.fingerprint {
if *fp != entry.item.fingerprint() {
issues.push(format!(
"question {} ({}): the item has been edited since this \
assessment; statistics from this administration describe \
the older wording",
p.number, p.item
));
}
}
if !p.key.is_empty() && p.key != entry.item.key_letters() {
issues.push(format!(
"question {} ({}): the recorded key {:?} differs from the \
item's current key {:?}",
p.number,
p.item,
p.key,
entry.item.key_letters()
));
}
}
}
}
}
issues
}
/// Estimated time to complete, in minutes.
///
/// # Arguments
///
/// * `catalog` - the loaded course, for per-item time estimates.
///
/// # Returns
///
/// The estimate.
pub fn estimated_minutes(&self, catalog: &Catalog) -> f64 {
let seconds: f64 = self
.items
.iter()
.filter_map(|p| catalog.get(&p.item))
.map(|e| e.item.expected_seconds())
.sum();
seconds / 60.0
}
/// A skeleton record for `coursebank assessment new`.
///
/// # Arguments
///
/// * `id` - the assessment id.
/// * `title` - the printed title.
/// * `kind` - the kind of assessment.
///
/// # Returns
///
/// A record with no items.
pub fn skeleton(id: &str, title: &str, kind: Kind) -> AssessmentFile {
AssessmentFile {
schema_version: SCHEMA_VERSION.to_string(),
assessment: Assessment {
id: id.to_string(),
title: title.to_string(),
term: None,
kind,
date: Some(Date::today()),
platform: Platform::Paper,
minutes_allowed: None,
attempts: None,
shuffle: None,
scoring_policy: None,
instructions: None,
notes: None,
},
blueprint: None,
forms: Vec::new(),
items: Vec::new(),
}
}
}
/// One recorded appearance of an item, derived from the assessment records.
#[derive(Debug, Clone)]
pub struct Usage {
/// The item's global id.
pub item: String,
/// The assessment id it appeared on.
pub assessment: String,
/// The assessment title.
pub title: String,
/// The kind of assessment.
pub kind: Kind,
/// When it was administered, when recorded.
pub date: Option<Date>,
/// The printed question number.
pub number: u32,
/// The fingerprint as used.
pub fingerprint: Option<String>,
}
/// The usage history of a course, built by scanning assessment records.
///
/// There is deliberately no separate ledger file. A ledger duplicates
/// information that the records already hold and then drifts away from it; this
/// derives the same answers from the one artifact that has to be right anyway.
#[derive(Debug, Clone, Default)]
pub struct History {
/// Every recorded appearance, newest last.
pub usages: Vec<Usage>,
}
impl History {
/// Builds the history from a directory of assessment records.
///
/// # Arguments
///
/// * `dir` - the assessments directory.
///
/// # Returns
///
/// The history.
///
/// # Errors
///
/// Propagates load errors.
pub fn load(dir: &Path) -> Result<History> {
let mut usages = Vec::new();
for file in AssessmentFile::load_all(dir)? {
for p in &file.items {
usages.push(Usage {
item: p.item.clone(),
assessment: file.assessment.id.clone(),
title: file.assessment.title.clone(),
kind: file.assessment.kind,
date: file.assessment.date,
number: p.number,
fingerprint: p.fingerprint.clone(),
});
}
}
usages.sort_by(|a, b| {
a.date
.cmp(&b.date)
.then(a.assessment.cmp(&b.assessment))
.then(a.number.cmp(&b.number))
});
Ok(History { usages })
}
/// Every appearance of one item, oldest first.
///
/// # Arguments
///
/// * `uid` - the item's global id.
///
/// # Returns
///
/// Matching usages.
pub fn for_item(&self, uid: &str) -> Vec<&Usage> {
self.usages.iter().filter(|u| u.item == uid).collect()
}
/// The most recent date an item was used.
///
/// # Arguments
///
/// * `uid` - the item's global id.
///
/// # Returns
///
/// The date, or `None` when never used or never dated.
pub fn last_used(&self, uid: &str) -> Option<Date> {
self.for_item(uid).iter().filter_map(|u| u.date).max()
}
/// How many times an item has been used.
///
/// # Arguments
///
/// * `uid` - the item's global id.
///
/// # Returns
///
/// The count.
pub fn use_count(&self, uid: &str) -> usize {
self.for_item(uid).len()
}
/// Whether an item is still inside its reuse cooldown.
///
/// # Arguments
///
/// * `uid` - the item's global id.
/// * `cooldown_days` - the minimum gap between uses.
/// * `as_of` - the date of the assessment being assembled.
///
/// # Returns
///
/// `true` when the item was used too recently to reuse.
pub fn in_cooldown(&self, uid: &str, cooldown_days: i64, as_of: Date) -> bool {
match self.last_used(uid) {
Some(last) => last.days_until(as_of) < cooldown_days,
None => false,
}
}
}
fn default_version() -> String {
SCHEMA_VERSION.to_string()
}
fn default_kind() -> Kind {
Kind::Exam
}
fn default_platform() -> Platform {
Platform::Paper
}
fn yes() -> bool {
true
}
fn is_false(b: &bool) -> bool {
!*b
}
#[cfg(test)]
mod tests {
use super::*;
fn record(src: &str) -> AssessmentFile {
serde_yaml_ng::from_str(src).expect("assessment parses")
}
const SIMPLE: &str = r#"
assessment:
id: exam-4-2026s
title: Exam 4
kind: exam
date: 2026-04-23
platform: paper
items:
- { number: 1, item: "b1::q-a-001", points: 1.5, key: [B], level: 1 }
- { number: 2, item: "b1::q-a-002", points: 1.5, key: [C], level: 3 }
- { number: 3, item: "b1::q-a-003", points: 1.5, bonus: true, key: [D], level: 5 }
"#;
#[test]
fn parses_and_totals_only_scored_points() {
let a = record(SIMPLE);
assert_eq!(a.items.len(), 3);
assert_eq!(a.total_points(1.0), 3.0, "bonus is excluded");
assert_eq!(a.placement(2).unwrap().item, "b1::q-a-002");
assert!(a.validate(None).is_empty(), "{:?}", a.validate(None));
}
#[test]
fn level_counts_skip_bonus() {
let counts = record(SIMPLE).level_counts();
assert_eq!(counts.get(&Level::Remember), Some(&1));
assert_eq!(counts.get(&Level::Apply), Some(&1));
assert_eq!(counts.get(&Level::Create), None);
}
#[test]
fn catches_duplicate_numbers_and_repeated_items() {
let a = record(
r#"
assessment: { id: x, title: X }
items:
- { number: 1, item: "b::q-1" }
- { number: 1, item: "b::q-1" }
"#,
);
let issues = a.validate(None);
assert!(issues.iter().any(|i| i.contains("used 2 times")));
assert!(issues.iter().any(|i| i.contains("appears twice")));
}
#[test]
fn catches_non_contiguous_numbering() {
let a = record(
r#"
assessment: { id: x, title: X }
items:
- { number: 1, item: "b::q-1" }
- { number: 3, item: "b::q-2" }
"#,
);
assert!(a
.validate(None)
.iter()
.any(|i| i.contains("not contiguous")));
}
#[test]
fn rejects_out_of_range_credit_overrides() {
let a = record(
r#"
assessment: { id: x, title: X }
items:
- { number: 1, item: "b::q-1", credit_overrides: { B: 1.5 } }
"#,
);
assert!(a
.validate(None)
.iter()
.any(|i| i.contains("must be in [0, 1]")));
}
#[test]
fn history_tracks_last_use_and_cooldown() {
let h = History {
usages: vec![
Usage {
item: "b::q-1".into(),
assessment: "exam-1".into(),
title: "Exam 1".into(),
kind: Kind::Exam,
date: Some("2025-09-15".parse().unwrap()),
number: 4,
fingerprint: None,
},
Usage {
item: "b::q-1".into(),
assessment: "exam-3".into(),
title: "Exam 3".into(),
kind: Kind::Exam,
date: Some("2026-02-10".parse().unwrap()),
number: 7,
fingerprint: None,
},
],
};
assert_eq!(h.use_count("b::q-1"), 2);
assert_eq!(h.use_count("b::q-2"), 0);
assert_eq!(h.last_used("b::q-1").unwrap().to_string(), "2026-02-10");
let exam_date: Date = "2026-04-23".parse().unwrap();
// 72 days elapsed, so a 90-day cooldown still blocks it and a 60-day one
// does not.
assert!(h.in_cooldown("b::q-1", 90, exam_date));
assert!(!h.in_cooldown("b::q-1", 60, exam_date));
assert!(!h.in_cooldown("b::never-used", 3650, exam_date));
}
#[test]
fn blueprint_totals() {
let b: Blueprint = serde_yaml_ng::from_str(
r#"
level_counts: { 1: 6, 2: 10, 3: 12, 4: 8 }
bonus_counts: { 5: 2 }
"#,
)
.unwrap();
assert_eq!(b.scored_total(), 36);
assert_eq!(b.bonus_total(), 2);
}
}
+906
View File
@@ -0,0 +1,906 @@
//! The bank file: a collection of items scoped to a topic or a lecture.
//!
//! One bank per topic (or per lecture, if that suits how you teach) is the unit
//! of authoring. Banks are small enough to review in a pull request, they let
//! two people write questions without colliding, and `bank.scope` records what
//! the file is *for* so `coursebank catalog` can tell you that you have eleven
//! items on enzyme kinetics and none on regulation.
//!
//! Validation here is split in two on purpose. [`BankFile::validate`] checks what
//! must be true for the file to be usable at all: ids are unique, a keyed answer
//! exists, an approved item is fully specified, a level and its cognitive process
//! agree. The softer question of whether an item is *well written* lives in
//! [`crate::lint`], because those checks are advisory and you should be able to
//! ship a file that trips a few of them.
use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::course::{CourseFile, SCHEMA_VERSION};
use crate::date::Date;
use crate::error::Result;
use crate::item::Item;
use crate::taxonomy::{Format, Level, Status};
use crate::yaml;
/// A whole bank file.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BankFile {
/// Schema version this file targets.
#[serde(
default = "default_version",
deserialize_with = "yaml::flexible_string"
)]
pub schema_version: String,
/// Bank identity and scope.
pub bank: BankMeta,
/// Values applied to every item in the file that does not set its own.
#[serde(default)]
pub defaults: BankDefaults,
/// The items.
#[serde(default)]
pub items: Vec<Item>,
}
/// Bank identity and scope.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BankMeta {
/// Stable id, unique across the course. Item ids are namespaced by it.
pub id: String,
/// Human title.
pub title: String,
/// What this bank covers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// What the bank is scoped to, so coverage can be reported against it.
#[serde(default)]
pub scope: Scope,
/// Who maintains it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub maintainer: Option<String>,
/// When it was created.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<Date>,
/// When it was last touched.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated: Option<Date>,
}
/// What a bank is scoped to.
///
/// A bank may be scoped by lecture, by objective, by topic, or by none of them.
/// Declaring the scope is what lets the catalog report *gaps*: it can only tell
/// you that lecture 12 has no Apply-level items if it knows lecture 12 is
/// supposed to be covered here.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Scope {
/// Lectures this bank draws from.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lectures: Vec<String>,
/// Objectives this bank is responsible for covering.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec<String>,
/// Units this bank belongs to.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub units: Vec<String>,
/// Topics this bank is about.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub topics: Vec<String>,
}
/// Per-file defaults, so common metadata is written once.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BankDefaults {
/// Default author for items in this file.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
/// Default point value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub points: Option<f64>,
/// Expected option count; the linter flags items that differ, since an
/// inconsistent option count across a form is itself a cue to students.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub options_per_item: Option<usize>,
/// Topics added to every item in the file.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub topics: Vec<String>,
/// Sources applied to items that declare none.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lectures: Vec<String>,
}
impl BankFile {
/// Loads a bank file from disk.
///
/// # Arguments
///
/// * `path` - the YAML file.
///
/// # Returns
///
/// The parsed bank.
///
/// # Errors
///
/// Returns [`crate::error::Error::Io`] or [`crate::error::Error::Yaml`].
pub fn load(path: &Path) -> Result<BankFile> {
yaml::read(path)
}
/// Writes a bank file back out as YAML.
///
/// Round-tripping loses comments, which is why calibration is written by an
/// explicit `coursebank calibrate` step rather than as a side effect of
/// anything else: you should be able to see the diff it produces.
///
/// # Arguments
///
/// * `path` - the destination.
///
/// # Errors
///
/// Returns [`crate::error::Error::Io`] on a write failure.
pub fn save(&self, path: &Path) -> Result<()> {
yaml::write(path, self)
}
/// Applies file defaults to items that omit the corresponding field.
///
/// Called after loading so the rest of the crate never has to think about
/// defaults again.
pub fn apply_defaults(&mut self) {
let d = self.defaults.clone();
for item in &mut self.items {
if item.author.is_none() {
item.author = d.author.clone();
}
if item.points.is_none() {
item.points = d.points;
}
for t in &d.topics {
if !item.topics.contains(t) {
item.topics.push(t.clone());
}
}
if item.sources.is_empty() {
for lec in &d.lectures {
item.sources.push(crate::item::Source {
lecture: lec.clone(),
slides: Vec::new(),
readings: Vec::new(),
recording_seconds: None,
});
}
}
}
}
/// Loads a bank and applies its defaults.
///
/// # Arguments
///
/// * `path` - the YAML file.
///
/// # Returns
///
/// The parsed bank with defaults resolved.
///
/// # Errors
///
/// Propagates load errors.
pub fn load_resolved(path: &Path) -> Result<BankFile> {
let mut b = BankFile::load(path)?;
b.apply_defaults();
Ok(b)
}
/// Checks every invariant that must hold for the file to be usable.
///
/// Returns all problems rather than the first, so one run fixes one file.
/// When a course file is supplied, cross-file references are checked too.
///
/// # Arguments
///
/// * `course` - the course registry, for resolving objective and lecture
/// references. Pass `None` to check only what is local to the file.
///
/// # Returns
///
/// Every problem found, empty when the file is sound.
pub fn validate(&self, course: Option<&CourseFile>) -> Vec<String> {
let mut issues = Vec::new();
if self.bank.id.trim().is_empty() {
issues.push("bank.id is empty".into());
}
if self.bank.title.trim().is_empty() {
issues.push("bank.title is empty".into());
}
if let (Some(created), Some(updated)) = (self.bank.created, self.bank.updated) {
if updated < created {
issues.push(format!(
"bank.updated ({updated}) is before bank.created ({created})"
));
}
}
// Duplicate item ids inside the file.
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
for it in &self.items {
*counts.entry(it.id.as_str()).or_insert(0) += 1;
}
for (id, n) in &counts {
if *n > 1 {
issues.push(format!("duplicate item id `{id}` appears {n} times"));
}
}
if let Some(c) = course {
for lec in &self.bank.scope.lectures {
if !c.lectures.contains_key(lec) {
issues.push(format!("bank.scope: unknown lecture `{lec}`"));
}
}
for lo in &self.bank.scope.learning_objectives {
if !c.learning_objectives.contains_key(lo) {
issues.push(format!("bank.scope: unknown learning objective `{lo}`"));
}
}
}
for it in &self.items {
issues.extend(
validate_item(it, course, self.defaults.options_per_item)
.into_iter()
.map(|m| format!("{}: {m}", it.id)),
);
}
issues
}
/// Items that may be placed on a graded assessment.
///
/// # Returns
///
/// References to approved, unretired items.
pub fn assemblable(&self) -> Vec<&Item> {
self.items.iter().filter(|i| i.is_assemblable()).collect()
}
/// Counts of assemblable, non-bonus items by level.
///
/// This is the five-tuple you check a blueprint against.
///
/// # Returns
///
/// A map from level to count.
pub fn level_counts(&self) -> BTreeMap<Level, usize> {
let mut out: BTreeMap<Level, usize> = Level::ALL.iter().map(|l| (*l, 0)).collect();
for it in self.items.iter().filter(|i| i.is_assemblable() && !i.bonus) {
*out.entry(it.level).or_insert(0) += 1;
}
out
}
/// A skeleton bank file for `coursebank bank new`.
///
/// # Arguments
///
/// * `id` - the bank id.
/// * `title` - the bank title.
///
/// # Returns
///
/// A bank with no items.
pub fn skeleton(id: &str, title: &str) -> BankFile {
BankFile {
schema_version: SCHEMA_VERSION.to_string(),
bank: BankMeta {
id: id.to_string(),
title: title.to_string(),
description: None,
scope: Scope::default(),
maintainer: None,
created: Some(Date::today()),
updated: Some(Date::today()),
},
defaults: BankDefaults::default(),
items: Vec::new(),
}
}
}
/// Validates one item.
///
/// # Arguments
///
/// * `it` - the item.
/// * `course` - the course registry, when available.
/// * `expected_options` - the file's declared option count, when set.
///
/// # Returns
///
/// Problems found, without the item id prefix.
fn validate_item(
it: &Item,
course: Option<&CourseFile>,
expected_options: Option<usize>,
) -> Vec<String> {
let mut issues = Vec::new();
if it.id.trim().is_empty() {
issues.push("empty id".into());
}
if it.stem.trim().is_empty() {
issues.push("empty stem".into());
}
if it.version == 0 {
issues.push("version must be at least 1".into());
}
// --- options -----------------------------------------------------------
if it.options.len() < 2 {
issues.push(format!(
"needs at least 2 options, has {}",
it.options.len()
));
}
let mut seen: Vec<&str> = Vec::new();
for (i, o) in it.options.iter().enumerate() {
let pos = i + 1;
if o.text.trim().is_empty() {
issues.push(format!("option {pos}: empty text"));
}
let letter_ok = o.id.len() == 1
&& o.id
.chars()
.next()
.map(|c| c.is_ascii_uppercase() && c <= 'H')
.unwrap_or(false);
if !letter_ok {
issues.push(format!(
"option {pos}: id `{}` must be a single letter A through H",
o.id
));
}
if seen.contains(&o.id.as_str()) {
issues.push(format!("option {pos}: duplicate option id `{}`", o.id));
}
seen.push(&o.id);
let credit = o.credit();
if !(0.0..=1.0).contains(&credit) {
issues.push(format!(
"option {}: credit must be between 0 and 1, got {credit}",
o.id
));
}
// Partial credit must be argued for in writing, not remembered.
if o.is_partial() && o.defense.is_none() {
issues.push(format!(
"option {}: awards credit {credit} but gives no `defense`",
o.id
));
}
if o.is_partial() && !o.defensible {
issues.push(format!(
"option {}: awards credit {credit} but is not marked `defensible: true`",
o.id
));
}
if o.correct && credit == 0.0 {
issues.push(format!(
"option {}: keyed correct but earns no credit",
o.id
));
}
}
if let Some(n) = expected_options {
if it.options.len() != n && !it.options.is_empty() {
issues.push(format!(
"has {} options but the bank declares {n} per item",
it.options.len()
));
}
}
// --- key ---------------------------------------------------------------
let keys = it.key_indices();
match it.format {
Format::SingleBestAnswer => {
if keys.len() != 1 {
issues.push(format!(
"single_best_answer needs exactly one keyed option, has {}",
keys.len()
));
}
}
Format::MultipleResponse => {
if keys.is_empty() {
issues.push("multiple_response needs at least one keyed option".into());
}
if keys.len() == it.options.len() {
issues.push("multiple_response keys every option, so it asks nothing".into());
}
}
Format::TrueFalse => {
if it.options.len() != 2 {
issues.push(format!(
"true_false needs exactly 2 options, has {}",
it.options.len()
));
}
if keys.len() != 1 {
issues.push("true_false needs exactly one keyed option".into());
}
}
}
// --- level and process must agree -------------------------------------
if let Some(p) = it.cognitive_process {
if !it.level.allows(p) {
issues.push(format!(
"cognitive_process `{p}` belongs to level {} but the item is level {}",
p.level().code(),
it.level.code()
));
}
}
// --- design plausibility ----------------------------------------------
if let Some(d) = &it.design {
if let Some(x) = d.expected_difficulty {
if !(0.0..=1.0).contains(&x) {
issues.push(format!(
"design.expected_difficulty must be between 0 and 1, got {x}"
));
}
}
if let Some(t) = d.expected_time_seconds {
if t <= 0.0 {
issues.push(format!(
"design.expected_time_seconds must be positive, got {t}"
));
}
}
}
// --- calibration plausibility -----------------------------------------
if let Some(c) = &it.calibration {
if let Some(p) = c.p_value {
if !(0.0..=1.0).contains(&p) {
issues.push(format!(
"calibration.p_value must be between 0 and 1, got {p}"
));
}
}
if let Some(r) = c.point_biserial {
if !(-1.0..=1.0).contains(&r) {
issues.push(format!(
"calibration.point_biserial must be between -1 and 1, got {r}"
));
}
}
for (letter, _) in &c.option_stats {
if it.option(letter).is_none() {
issues.push(format!(
"calibration.option_stats has `{letter}`, which is not an option of this item"
));
}
}
if let Some(irt) = &c.irt {
if irt.a <= 0.0 {
issues.push(format!("calibration.irt.a must be positive, got {}", irt.a));
}
if let Some(cp) = irt.c {
if !(0.0..1.0).contains(&cp) {
issues.push(format!("calibration.irt.c must be in [0, 1), got {cp}"));
}
}
}
}
// --- history must be coherent -----------------------------------------
let mut last_version = 0u32;
for (i, h) in it.history.iter().enumerate() {
if h.version <= last_version {
issues.push(format!(
"history entry {} has version {} which does not increase",
i + 1,
h.version
));
}
last_version = h.version;
}
if !it.history.is_empty() && last_version > it.version {
issues.push(format!(
"history records version {last_version} but the item says version {}",
it.version
));
}
// --- retirement -------------------------------------------------------
if it.retired.is_some() && it.status != Status::Retired {
issues.push(format!(
"has a `retired` block but status is `{}`",
it.status
));
}
// --- 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 {
if it.cognitive_process.is_none() {
issues.push("approved items must declare a cognitive_process".into());
}
if it.learning_objectives.is_empty() {
issues.push("approved items must reference at least one learning objective".into());
}
if it.sources.is_empty() {
issues.push("approved items must cite at least one source".into());
}
if it.design.is_none() {
issues.push("approved items must carry a design block".into());
}
}
// --- cross-file references --------------------------------------------
if let Some(c) = course {
for lo in &it.learning_objectives {
match c.learning_objectives.get(lo) {
None => issues.push(format!("unknown learning objective `{lo}`")),
Some(obj) => {
if let Some(ceiling) = obj.level_ceiling {
if it.level > ceiling {
issues.push(format!(
"level {} exceeds the ceiling {} declared for objective `{lo}`",
it.level.code(),
ceiling.code()
));
}
}
if !obj.assessed {
issues.push(format!(
"objective `{lo}` is marked `assessed: false` but this item measures it"
));
}
}
}
}
for s in &it.sources {
if !c.lectures.contains_key(&s.lecture) {
issues.push(format!("unknown lecture `{}`", s.lecture));
}
}
if let Some(st) = &it.stimulus {
if !c.stimuli.contains_key(st) {
issues.push(format!("unknown stimulus `{st}`"));
}
}
if let Some(floor) = c.policy.partial_credit_floor_level {
for o in &it.options {
if o.is_partial() && it.level < floor {
issues.push(format!(
"option {} awards partial credit at level {}, below the course floor of {}",
o.id,
it.level.code(),
floor.code()
));
}
}
}
if !c.policy.allow_partial_credit && it.options.iter().any(|o| o.is_partial()) {
issues.push("awards partial credit, which the course policy disallows".into());
}
}
issues
}
fn default_version() -> String {
SCHEMA_VERSION.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn bank(items_yaml: &str) -> BankFile {
let src = format!("bank:\n id: b\n title: Bank\ndefaults: {{}}\nitems:\n{items_yaml}");
let mut b: BankFile = serde_yaml_ng::from_str(&src).expect("bank parses");
b.apply_defaults();
b
}
#[test]
fn sound_bank_validates_clean() {
let b = bank(
r#"
- id: q-a-001
status: draft
level: 1
stem: What is x?
options:
- { id: A, text: right, correct: true }
- { id: B, text: wrong }
- { id: C, text: wrong too }
"#,
);
assert!(b.validate(None).is_empty(), "{:?}", b.validate(None));
}
#[test]
fn catches_missing_and_multiple_keys() {
let b = bank(
r#"
- id: q-a-001
status: draft
level: 1
stem: s
options:
- { id: A, text: a }
- { id: B, text: b }
- id: q-a-002
status: draft
level: 1
format: single_best_answer
stem: s
options:
- { id: A, text: a, correct: true }
- { id: B, text: b, correct: true }
"#,
);
let issues = b.validate(None);
assert!(issues
.iter()
.any(|i| i.contains("exactly one keyed option")));
assert_eq!(
issues
.iter()
.filter(|i| i.contains("exactly one keyed option"))
.count(),
2
);
}
#[test]
fn catches_duplicate_ids_and_letters() {
let b = bank(
r#"
- id: q-a-001
status: draft
level: 1
stem: s
options:
- { id: A, text: a, correct: true }
- { id: A, text: b }
- id: q-a-001
status: draft
level: 1
stem: s
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
"#,
);
let issues = b.validate(None);
assert!(issues.iter().any(|i| i.contains("duplicate item id")));
assert!(issues.iter().any(|i| i.contains("duplicate option id")));
}
#[test]
fn level_and_process_must_agree() {
let b = bank(
r#"
- id: q-a-001
status: draft
level: 3
cognitive_process: recall
stem: s
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
"#,
);
let issues = b.validate(None);
assert!(
issues.iter().any(|i| i.contains("belongs to level 1")),
"{issues:?}"
);
}
#[test]
fn approval_requires_full_specification() {
let b = bank(
r#"
- id: q-a-001
status: approved
level: 1
stem: s
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
"#,
);
let issues = b.validate(None);
for want in [
"cognitive_process",
"learning objective",
"source",
"design block",
] {
assert!(
issues.iter().any(|i| i.contains(want)),
"expected a complaint about {want}, got {issues:?}"
);
}
}
#[test]
fn partial_credit_needs_a_written_defense() {
let b = bank(
r#"
- id: q-a-001
status: draft
level: 5
stem: s
options:
- { id: A, text: a, correct: true }
- { id: B, text: b, credit: 0.5 }
"#,
);
let issues = b.validate(None);
assert!(issues.iter().any(|i| i.contains("no `defense`")));
assert!(issues.iter().any(|i| i.contains("defensible: true")));
}
#[test]
fn defaults_fill_in_items() {
let src = r#"
bank: { id: b, title: Bank }
defaults:
author: Alex
points: 1.5
topics: [kinetics]
lectures: [L11]
items:
- id: q-a-001
status: draft
level: 1
stem: s
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
- id: q-a-002
status: draft
level: 1
stem: s
author: Someone Else
topics: [kinetics]
sources: [{ lecture: L12 }]
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
"#;
let mut b: BankFile = serde_yaml_ng::from_str(src).unwrap();
b.apply_defaults();
assert_eq!(b.items[0].author.as_deref(), Some("Alex"));
assert_eq!(b.items[0].points, Some(1.5));
assert_eq!(b.items[0].topics, vec!["kinetics"]);
assert_eq!(b.items[0].sources[0].lecture, "L11");
// Explicit values win, and topics are not duplicated.
assert_eq!(b.items[1].author.as_deref(), Some("Someone Else"));
assert_eq!(b.items[1].topics, vec!["kinetics"]);
assert_eq!(b.items[1].sources[0].lecture, "L12");
}
#[test]
fn cross_file_references_are_checked_against_the_course() {
let course: CourseFile = serde_yaml_ng::from_str(
r#"
course: { code: X, title: Y, term: Z }
lectures:
L11: { title: Kinetics }
learning_objectives:
lo-known: { text: Do the thing, level_ceiling: 2 }
"#,
)
.unwrap();
let b = bank(
r#"
- id: q-a-001
status: draft
level: 4
stem: s
learning_objectives: [lo-known, lo-unknown]
sources: [{ lecture: L99 }]
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
"#,
);
let issues = b.validate(Some(&course));
assert!(issues
.iter()
.any(|i| i.contains("unknown learning objective `lo-unknown`")));
assert!(issues.iter().any(|i| i.contains("unknown lecture `L99`")));
assert!(
issues.iter().any(|i| i.contains("exceeds the ceiling")),
"{issues:?}"
);
}
#[test]
fn history_versions_must_increase() {
let b = bank(
r#"
- id: q-a-001
version: 2
status: draft
level: 1
stem: s
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
history:
- { version: 2, date: 2026-01-01, change: second }
- { version: 1, date: 2026-01-02, change: first }
"#,
);
let issues = b.validate(None);
assert!(issues.iter().any(|i| i.contains("does not increase")));
}
#[test]
fn level_counts_exclude_drafts_and_bonuses() {
let b = bank(
r#"
- id: q-a-001
status: approved
level: 1
cognitive_process: recall
stem: s
learning_objectives: [lo]
sources: [{ lecture: L1 }]
design: { expected_difficulty: 0.8 }
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
- id: q-a-002
status: draft
level: 1
stem: s
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
- id: q-a-003
status: approved
level: 5
cognitive_process: generate
bonus: true
stem: s
learning_objectives: [lo]
sources: [{ lecture: L1 }]
design: { expected_difficulty: 0.3 }
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
"#,
);
let counts = b.level_counts();
assert_eq!(counts[&Level::Remember], 1);
assert_eq!(counts[&Level::Create], 0, "bonus items are not scored");
assert_eq!(b.assemblable().len(), 2);
}
}
+640
View File
@@ -0,0 +1,640 @@
//! Loading a whole course at once, and reporting on what it contains.
//!
//! A [`Catalog`] is every bank in a course, indexed so that an item can be found
//! by its global id (`bank::item`), and so that questions like "how many Apply
//! level items do I have on lecture 12" have a cheap answer.
//!
//! The global id is the join key for everything downstream: assessment records
//! reference it, response tables carry it, and usage history is keyed by it. Bank
//! ids therefore have to be unique across a course, which the catalog enforces
//! at load time rather than letting a silent collision merge two items.
//!
//! The coverage report is the part that changes how you write. It is easy to
//! accumulate forty recall items and believe a topic is covered; a table showing
//! that eleven objectives have no item above level 1 is harder to ignore.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use crate::bank::BankFile;
use crate::course::{CourseFile, Layout};
use crate::error::{Error, Result};
use crate::item::Item;
use crate::taxonomy::{Level, Status};
use crate::yaml;
/// One item plus everything needed to locate it again.
#[derive(Debug, Clone)]
pub struct Entry {
/// The globally unique id, `bank::item`.
pub uid: String,
/// The bank id.
pub bank: String,
/// The file the item came from.
pub path: PathBuf,
/// Position within the bank file, for stable ordering.
pub index: usize,
/// The item itself.
pub item: Item,
}
impl Entry {
/// The item's point value, resolving against the course policy.
///
/// # Arguments
///
/// * `course` - the course whose policy supplies the default.
///
/// # Returns
///
/// The point value.
pub fn points(&self, course: &CourseFile) -> f64 {
self.item.points(course.policy.points_per_item)
}
}
/// Every bank in a course, indexed.
#[derive(Debug, Clone)]
pub struct Catalog {
/// The course registry.
pub course: CourseFile,
/// The resolved directory layout.
pub layout: Layout,
/// Every item, in stable order: by bank id, then by position in the file.
pub entries: Vec<Entry>,
/// Bank metadata by bank id.
pub banks: BTreeMap<String, crate::bank::BankMeta>,
/// Map from global id to index into `entries`.
index: BTreeMap<String, usize>,
}
impl Catalog {
/// Loads a whole course from its directory.
///
/// # Arguments
///
/// * `root` - the course directory containing `course.yaml` and `banks/`.
///
/// # Returns
///
/// The catalog.
///
/// # Errors
///
/// Returns a load error for the course file or any bank, and
/// [`Error::Invalid`] when two banks share an id or two items share a global
/// id, since either makes the join key ambiguous.
pub fn load(root: &Path) -> Result<Catalog> {
let layout = Layout::new(root);
let course = CourseFile::load(&layout.course_file())?;
let mut catalog = Catalog {
course,
layout,
entries: Vec::new(),
banks: BTreeMap::new(),
index: BTreeMap::new(),
};
let mut problems = Vec::new();
let mut files = yaml::list_yaml(&catalog.layout.banks())?;
files.sort();
for path in files {
let bank = BankFile::load_resolved(&path)?;
let bank_id = bank.bank.id.clone();
if let Some(existing) = catalog.banks.get(&bank_id) {
problems.push(format!(
"bank id `{bank_id}` is used by two files (`{}` and `{}`)",
existing.title, bank.bank.title
));
continue;
}
catalog.banks.insert(bank_id.clone(), bank.bank.clone());
for (i, item) in bank.items.into_iter().enumerate() {
let uid = format!("{bank_id}::{}", item.id);
if catalog.index.contains_key(&uid) {
problems.push(format!("duplicate global item id `{uid}`"));
continue;
}
catalog.index.insert(uid.clone(), catalog.entries.len());
catalog.entries.push(Entry {
uid,
bank: bank_id.clone(),
path: path.clone(),
index: i,
item,
});
}
}
if !problems.is_empty() {
return Err(Error::Invalid(problems));
}
Ok(catalog)
}
/// Looks up an item by global id.
///
/// # Arguments
///
/// * `uid` - the global id, `bank::item`.
///
/// # Returns
///
/// The entry, or `None`.
pub fn get(&self, uid: &str) -> Option<&Entry> {
self.index.get(uid).map(|i| &self.entries[*i])
}
/// Looks up an item by global id, erroring when absent.
///
/// # Arguments
///
/// * `uid` - the global id.
///
/// # Returns
///
/// The entry.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when no such item exists.
pub fn require(&self, uid: &str) -> Result<&Entry> {
self.get(uid).ok_or_else(|| Error::Unresolved {
kind: "item",
id: uid.to_string(),
context: None,
})
}
/// Resolves a possibly-unqualified id to a global id.
///
/// Typing `q-mm-kinetics-001` on the command line should work when that id is
/// unambiguous across the course, because remembering which bank a question
/// lives in is exactly the sort of bookkeeping this tool exists to remove.
///
/// # Arguments
///
/// * `id` - a global id, or a bare item id.
///
/// # Returns
///
/// The global id.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when nothing matches, or [`Error::Usage`]
/// when a bare id matches items in more than one bank.
pub fn resolve(&self, id: &str) -> Result<String> {
if self.index.contains_key(id) {
return Ok(id.to_string());
}
let matches: Vec<&Entry> = self.entries.iter().filter(|e| e.item.id == id).collect();
match matches.len() {
0 => Err(Error::Unresolved {
kind: "item",
id: id.to_string(),
context: None,
}),
1 => Ok(matches[0].uid.clone()),
_ => Err(Error::usage(format!(
"`{id}` is ambiguous; it exists in {}. Use the full `bank::item` form.",
matches
.iter()
.map(|e| e.bank.as_str())
.collect::<Vec<_>>()
.join(", ")
))),
}
}
/// Every item that may be placed on a graded assessment.
///
/// # Returns
///
/// References to approved, unretired entries.
pub fn assemblable(&self) -> Vec<&Entry> {
self.entries
.iter()
.filter(|e| e.item.is_assemblable())
.collect()
}
/// Validates every bank against the course registry.
///
/// # Returns
///
/// Problems, prefixed with the file they came from.
pub fn validate(&self) -> Result<Vec<String>> {
let mut issues: Vec<String> = self
.course
.validate()
.into_iter()
.map(|m| format!("course.yaml: {m}"))
.collect();
for path in yaml::list_yaml(&self.layout.banks())? {
let bank = BankFile::load_resolved(&path)?;
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string();
issues.extend(
bank.validate(Some(&self.course))
.into_iter()
.map(|m| format!("{name}: {m}")),
);
}
Ok(issues)
}
/// Counts of assemblable, non-bonus items by level.
///
/// # Returns
///
/// A map from level to count, with every level present.
pub fn level_counts(&self) -> BTreeMap<Level, usize> {
let mut out: BTreeMap<Level, usize> = Level::ALL.iter().map(|l| (*l, 0)).collect();
for e in self.assemblable().iter().filter(|e| !e.item.bonus) {
*out.entry(e.item.level).or_insert(0) += 1;
}
out
}
/// Counts of items by workflow status, over the whole course.
///
/// # Returns
///
/// A map from status to count.
pub fn status_counts(&self) -> BTreeMap<Status, usize> {
let mut out: BTreeMap<Status, usize> = BTreeMap::new();
for e in &self.entries {
*out.entry(e.item.status).or_insert(0) += 1;
}
out
}
/// Items that measure a given objective.
///
/// # Arguments
///
/// * `objective` - the objective id.
///
/// # Returns
///
/// Matching entries.
pub fn by_objective(&self, objective: &str) -> Vec<&Entry> {
self.entries
.iter()
.filter(|e| e.item.learning_objectives.iter().any(|o| o == objective))
.collect()
}
/// Items sourced to a given lecture.
///
/// # Arguments
///
/// * `lecture` - the lecture id.
///
/// # Returns
///
/// Matching entries.
pub fn by_lecture(&self, lecture: &str) -> Vec<&Entry> {
self.entries
.iter()
.filter(|e| e.item.sources.iter().any(|s| s.lecture == lecture))
.collect()
}
/// Items carrying a given topic tag.
///
/// # Arguments
///
/// * `topic` - the topic tag.
///
/// # Returns
///
/// Matching entries.
pub fn by_topic(&self, topic: &str) -> Vec<&Entry> {
self.entries
.iter()
.filter(|e| e.item.topics.iter().any(|t| t == topic))
.collect()
}
/// Every topic tag in use, with counts.
///
/// # Returns
///
/// A map from topic to item count.
pub fn topics(&self) -> BTreeMap<String, usize> {
let mut out: BTreeMap<String, usize> = BTreeMap::new();
for e in &self.entries {
for t in &e.item.topics {
*out.entry(t.clone()).or_insert(0) += 1;
}
}
out
}
/// Builds the coverage report.
///
/// # Returns
///
/// One row per assessed objective plus a list of course-wide gaps.
pub fn coverage(&self) -> Coverage {
let mut rows = Vec::new();
for id in self.course.objectives_in_order() {
let obj = &self.course.learning_objectives[&id];
if !obj.assessed {
continue;
}
let items = self.by_objective(&id);
let usable: Vec<&&Entry> = items.iter().filter(|e| e.item.is_assemblable()).collect();
let mut levels: BTreeSet<Level> = BTreeSet::new();
for e in &usable {
levels.insert(e.item.level);
}
rows.push(CoverageRow {
objective: id.clone(),
text: obj.text.clone(),
unit: obj.unit.clone(),
total: items.len(),
assemblable: usable.len(),
max_level: levels.iter().next_back().copied(),
levels: levels.into_iter().collect(),
ceiling: obj.level_ceiling,
});
}
let mut gaps = Vec::new();
for row in &rows {
if row.total == 0 {
gaps.push(Gap::Uncovered(row.objective.clone()));
} else if row.assemblable == 0 {
gaps.push(Gap::NoApprovedItems(row.objective.clone()));
} else if row.assemblable == 1 {
gaps.push(Gap::SingleItem(row.objective.clone()));
}
// An objective assessed only at the recall level is the most common
// and most consequential blind spot: it looks covered in a count and
// is not covered in fact.
if row.assemblable > 0 && row.max_level == Some(Level::Remember) {
if let Some(ceiling) = row.ceiling {
if ceiling > Level::Remember {
gaps.push(Gap::RecallOnly(row.objective.clone()));
}
} else {
gaps.push(Gap::RecallOnly(row.objective.clone()));
}
}
}
for (lec_id, _) in &self.course.lectures {
if self
.by_lecture(lec_id)
.iter()
.filter(|e| e.item.is_assemblable())
.count()
== 0
{
gaps.push(Gap::LectureUnassessed(lec_id.clone()));
}
}
// Items with no objective at all cannot appear in any student report.
for e in &self.entries {
if e.item.learning_objectives.is_empty() && e.item.is_assemblable() {
gaps.push(Gap::ItemWithoutObjective(e.uid.clone()));
}
}
Coverage { rows, gaps }
}
}
/// The coverage report.
#[derive(Debug, Clone)]
pub struct Coverage {
/// One row per assessed objective.
pub rows: Vec<CoverageRow>,
/// Course-wide gaps worth acting on.
pub gaps: Vec<Gap>,
}
/// Coverage of one objective.
#[derive(Debug, Clone)]
pub struct CoverageRow {
/// The objective id.
pub objective: String,
/// The objective text.
pub text: String,
/// The unit it belongs to.
pub unit: Option<String>,
/// Items referencing it, at any status.
pub total: usize,
/// Items that could actually be used.
pub assemblable: usize,
/// The highest level assessed.
pub max_level: Option<Level>,
/// Every level assessed.
pub levels: Vec<Level>,
/// The declared ceiling, when set.
pub ceiling: Option<Level>,
}
/// A specific, actionable hole in the item pool.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Gap {
/// An assessed objective with no items at all.
Uncovered(String),
/// An objective whose items are all drafts or retired.
NoApprovedItems(String),
/// An objective resting on a single item, so one bad item hides it entirely.
SingleItem(String),
/// An objective assessed only at the recall level despite a higher ceiling.
RecallOnly(String),
/// A lecture with no usable items.
LectureUnassessed(String),
/// A usable item that references no objective, so it can never appear in a
/// student report.
ItemWithoutObjective(String),
}
impl Gap {
/// A one-line description.
pub fn message(&self) -> String {
match self {
Gap::Uncovered(id) => format!("objective `{id}` has no items"),
Gap::NoApprovedItems(id) => {
format!("objective `{id}` has items but none are approved")
}
Gap::SingleItem(id) => {
format!("objective `{id}` rests on a single item; one bad item hides it")
}
Gap::RecallOnly(id) => {
format!("objective `{id}` is assessed only at level 1")
}
Gap::LectureUnassessed(id) => format!("lecture `{id}` has no usable items"),
Gap::ItemWithoutObjective(uid) => {
format!("item `{uid}` has no learning objective, so it cannot appear in a report")
}
}
}
/// How much attention the gap deserves.
pub fn severity(&self) -> Severity {
match self {
Gap::Uncovered(_) | Gap::NoApprovedItems(_) => Severity::High,
Gap::RecallOnly(_) | Gap::ItemWithoutObjective(_) => Severity::Medium,
Gap::SingleItem(_) | Gap::LectureUnassessed(_) => Severity::Low,
}
}
}
/// How much attention a finding deserves.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
/// Worth knowing.
Low,
/// Worth scheduling.
Medium,
/// Worth fixing before the next assessment.
High,
}
impl Severity {
/// A short label for report output.
pub fn label(self) -> &'static str {
match self {
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_course(dir: &Path, extra_lo: &str) {
std::fs::create_dir_all(dir.join("banks")).unwrap();
std::fs::write(
dir.join("course.yaml"),
format!(
r#"
course: {{ code: TEST 101, title: Testing, term: Fall 2026 }}
lectures:
L01: {{ title: One }}
L02: {{ title: Two }}
learning_objectives:
lo-covered: {{ text: Covered objective, lectures: [L01] }}
lo-bare: {{ text: Uncovered objective, lectures: [L02] }}
{extra_lo}
"#
),
)
.unwrap();
}
fn write_bank(dir: &Path, name: &str, body: &str) {
std::fs::write(dir.join("banks").join(name), body).unwrap();
}
fn tmp(tag: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("coursebank-test-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
const APPROVED: &str = r#"
bank:
id: b1
title: First bank
items:
- id: q-x-001
status: approved
level: 1
cognitive_process: recall
stem: What is x?
learning_objectives: [lo-covered]
sources: [{ lecture: L01 }]
design: { expected_difficulty: 0.8 }
options:
- { id: A, text: right, correct: true }
- { id: B, text: wrong }
"#;
#[test]
fn loads_and_indexes_items() {
let dir = tmp("load");
write_course(&dir, "");
write_bank(&dir, "b1.yaml", APPROVED);
let cat = Catalog::load(&dir).expect("catalog loads");
assert_eq!(cat.entries.len(), 1);
assert_eq!(cat.entries[0].uid, "b1::q-x-001");
assert!(cat.get("b1::q-x-001").is_some());
assert_eq!(cat.resolve("q-x-001").unwrap(), "b1::q-x-001");
assert!(cat.validate().unwrap().is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn duplicate_bank_ids_are_fatal() {
let dir = tmp("dupbank");
write_course(&dir, "");
write_bank(&dir, "a.yaml", APPROVED);
write_bank(&dir, "b.yaml", APPROVED);
let err = Catalog::load(&dir).expect_err("must reject colliding bank ids");
assert!(format!("{err}").contains("two files"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ambiguous_bare_ids_are_rejected() {
let dir = tmp("ambig");
write_course(&dir, "");
write_bank(&dir, "a.yaml", APPROVED);
write_bank(&dir, "b.yaml", &APPROVED.replace("id: b1", "id: b2"));
let cat = Catalog::load(&dir).expect("distinct banks load");
assert_eq!(cat.entries.len(), 2);
let err = cat.resolve("q-x-001").expect_err("bare id is ambiguous");
assert!(format!("{err}").contains("ambiguous"));
// The fully qualified form still works.
assert_eq!(cat.resolve("b2::q-x-001").unwrap(), "b2::q-x-001");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn coverage_finds_real_gaps() {
let dir = tmp("coverage");
write_course(&dir, "");
write_bank(&dir, "b1.yaml", APPROVED);
let cat = Catalog::load(&dir).unwrap();
let cov = cat.coverage();
assert_eq!(cov.rows.len(), 2);
assert!(cov.gaps.contains(&Gap::Uncovered("lo-bare".into())));
assert!(cov.gaps.contains(&Gap::SingleItem("lo-covered".into())));
assert!(cov.gaps.contains(&Gap::RecallOnly("lo-covered".into())));
assert!(cov.gaps.contains(&Gap::LectureUnassessed("L02".into())));
assert_eq!(Gap::Uncovered("x".into()).severity(), Severity::High);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn recall_only_respects_a_recall_ceiling() {
let dir = tmp("ceiling");
// An objective you only ever intend to test at level 1 is not a gap.
write_course(&dir, " lo-ceil: { text: Just recall, level_ceiling: 1 }");
write_bank(&dir, "b1.yaml", &APPROVED.replace("lo-covered", "lo-ceil"));
let cat = Catalog::load(&dir).unwrap();
let cov = cat.coverage();
assert!(!cov.gaps.contains(&Gap::RecallOnly("lo-ceil".into())));
let _ = std::fs::remove_dir_all(&dir);
}
}
+785
View File
@@ -0,0 +1,785 @@
//! The course file: identity plus the registries every bank references.
//!
//! Learning objectives and lectures are declared once, in `course.yaml`, and
//! referenced by id from items. That is the single most load-bearing decision in
//! the schema. It means an objective's wording lives in exactly one place, so
//! rewording it updates every report; it means a report can name what a student
//! missed by objective rather than by question number; and it means a dangling
//! reference is a hard error instead of a silently misspelled string that splits
//! your coverage table into two near-identical rows.
//!
//! The course file also declares the term. Items live across terms, so the term
//! belongs to the course and the administration, never to the item.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::date::Date;
use crate::error::{Error, Result};
use crate::taxonomy::Level;
use crate::yaml;
/// The schema version this build of the tool writes.
pub const SCHEMA_VERSION: &str = "1.0";
/// The canonical file name inside a course directory.
pub const COURSE_FILE: &str = "course.yaml";
/// A whole course file.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CourseFile {
/// Schema version this file targets.
#[serde(
default = "default_version",
deserialize_with = "yaml::flexible_string"
)]
pub schema_version: String,
/// Course identity.
pub course: Course,
/// Grading and assembly conventions that apply course-wide.
#[serde(default)]
pub policy: Policy,
/// Units or modules, ordered as taught.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub units: Vec<Unit>,
/// Lectures or sessions, keyed by id such as `L11`.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub lectures: BTreeMap<String, Lecture>,
/// Learning objectives, keyed by id such as `lo-mm-kinetics`.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub learning_objectives: BTreeMap<String, Objective>,
/// Shared stimuli for case-based testlets, keyed by id.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub stimuli: BTreeMap<String, Stimulus>,
}
/// Course identity.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Course {
/// Catalog code, e.g. `BIOSC 1540`.
pub code: String,
/// Full title.
pub title: String,
/// The term this instance runs in, e.g. `Spring 2026`.
pub term: String,
/// Granting institution.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub institution: Option<String>,
/// Instructors of record.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub instructors: Vec<String>,
/// A short slug used in generated file names; derived from `code` if absent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slug: Option<String>,
}
impl Course {
/// A filesystem-safe short name for this course.
///
/// # Returns
///
/// The declared slug, or one derived from the course code.
pub fn slug(&self) -> String {
match &self.slug {
Some(s) => s.clone(),
None => slugify(&self.code),
}
}
}
/// Course-wide conventions, so they are stated once rather than per assessment.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Policy {
/// Default points per scored item.
#[serde(default = "one")]
pub points_per_item: f64,
/// Default number of options an item should offer.
#[serde(default = "four")]
pub options_per_item: usize,
/// Levels that may carry bonus rather than scored items.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bonus_levels: Vec<Level>,
/// Whether partial credit for defensible distractors is permitted at all.
#[serde(default = "yes")]
pub allow_partial_credit: bool,
/// The lowest level at which a defensible wrong answer is considered
/// plausible. Awarding credit below this is flagged, because at the recall
/// level a "reasonable wrong answer" usually means the item is unclear.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub partial_credit_floor_level: Option<Level>,
/// Mastery threshold for objective-level reporting, as a proportion.
#[serde(default = "mastery_default")]
pub mastery_threshold: f64,
/// The fewest items on an objective before a report will call it mastered.
#[serde(default = "two_usize")]
pub min_items_for_mastery: usize,
}
impl Default for Policy {
fn default() -> Policy {
Policy {
points_per_item: 1.0,
options_per_item: 4,
bonus_levels: Vec::new(),
allow_partial_credit: true,
partial_credit_floor_level: None,
mastery_threshold: mastery_default(),
min_items_for_mastery: 2,
}
}
}
/// A unit or module of the course.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Unit {
/// Stable id, referenced by lectures and objectives.
pub id: String,
/// Human title.
pub title: String,
/// Optional longer description.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
/// A lecture or class session.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Lecture {
/// Human title.
pub title: String,
/// The date it ran, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub date: Option<Date>,
/// The unit it belongs to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
/// Where the slides live, for study guidance in student reports.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slides_url: Option<String>,
/// Assigned readings for the session.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub readings: Vec<String>,
}
/// A learning objective.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Objective {
/// The objective as you would state it to students. Reports quote this
/// verbatim, so write it in the second person and start with a verb.
pub text: String,
/// The unit it belongs to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
/// The lectures that develop it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lectures: Vec<String>,
/// The highest level you intend to assess this objective at. Assembling an
/// item above the ceiling is a warning: either the item overreaches or the
/// ceiling needs raising.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level_ceiling: Option<Level>,
/// Objectives that must be secure before this one is reachable. Student
/// reports walk this backwards to suggest where to start reviewing.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prerequisites: Vec<String>,
/// Free-form tags.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
/// Whether this objective is assessed at all, or is aspirational.
#[serde(default = "yes")]
pub assessed: bool,
}
/// A shared stem or vignette used by several items.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Stimulus {
/// The vignette body, in the markup described in `docs/AUTHORING.md`.
pub body: String,
/// An image or data file to reproduce alongside it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset: Option<String>,
/// A caption for the asset.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
}
impl CourseFile {
/// Loads a course file from disk without validating cross-references.
///
/// # Arguments
///
/// * `path` - path to `course.yaml`.
///
/// # Returns
///
/// The parsed course file.
///
/// # Errors
///
/// Returns [`Error::Io`] if unreadable and [`Error::Yaml`] if it does not
/// match the schema. Unknown keys are errors, so a misspelled field is
/// caught rather than ignored.
pub fn load(path: &Path) -> Result<CourseFile> {
yaml::read(path)
}
/// Finds and loads the course file for a course directory.
///
/// # Arguments
///
/// * `dir` - the course directory.
///
/// # Returns
///
/// The parsed course file.
///
/// # Errors
///
/// Propagates load errors, including absence of `course.yaml`.
pub fn load_dir(dir: &Path) -> Result<CourseFile> {
CourseFile::load(&dir.join(COURSE_FILE))
}
/// Writes the course file back out as YAML.
///
/// # Arguments
///
/// * `path` - destination path.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure.
pub fn save(&self, path: &Path) -> Result<()> {
yaml::write(path, self)
}
/// Checks internal consistency of the registries.
///
/// # Returns
///
/// Every problem found, empty when the file is sound.
pub fn validate(&self) -> Vec<String> {
let mut issues = Vec::new();
if self.course.code.trim().is_empty() {
issues.push("course.code is empty".into());
}
if self.course.title.trim().is_empty() {
issues.push("course.title is empty".into());
}
if self.course.term.trim().is_empty() {
issues.push("course.term is empty".into());
}
if !(0.0..=1.0).contains(&self.policy.mastery_threshold) {
issues.push(format!(
"policy.mastery_threshold must be between 0 and 1, got {}",
self.policy.mastery_threshold
));
}
let unit_ids: Vec<&String> = self.units.iter().map(|u| &u.id).collect();
let mut unit_counts: BTreeMap<&str, usize> = BTreeMap::new();
for u in &self.units {
*unit_counts.entry(u.id.as_str()).or_insert(0) += 1;
}
for (id, n) in &unit_counts {
if *n > 1 {
issues.push(format!("units: duplicate id `{id}` declared {n} times"));
}
}
for (id, lec) in &self.lectures {
if lec.title.trim().is_empty() {
issues.push(format!("lecture `{id}`: empty title"));
}
if let Some(u) = &lec.unit {
if !unit_ids.iter().any(|x| *x == u) {
issues.push(format!("lecture `{id}`: unknown unit `{u}`"));
}
}
}
for (id, lo) in &self.learning_objectives {
if lo.text.trim().is_empty() {
issues.push(format!("objective `{id}`: empty text"));
}
if let Some(u) = &lo.unit {
if !unit_ids.iter().any(|x| *x == u) {
issues.push(format!("objective `{id}`: unknown unit `{u}`"));
}
}
for lec in &lo.lectures {
if !self.lectures.contains_key(lec) {
issues.push(format!("objective `{id}`: unknown lecture `{lec}`"));
}
}
for pre in &lo.prerequisites {
if !self.learning_objectives.contains_key(pre) {
issues.push(format!(
"objective `{id}`: unknown prerequisite objective `{pre}`"
));
}
if pre == id {
issues.push(format!("objective `{id}`: lists itself as a prerequisite"));
}
}
}
issues.extend(self.prerequisite_cycles());
issues
}
/// Detects cycles in the objective prerequisite graph.
///
/// A cycle would make a study-order suggestion loop forever, so it is worth
/// catching at validation time rather than at report time.
///
/// # Returns
///
/// One message per objective that participates in a cycle.
fn prerequisite_cycles(&self) -> Vec<String> {
// Iterative depth-first search with three colors.
#[derive(PartialEq, Clone, Copy)]
enum Mark {
White,
Gray,
Black,
}
let mut color: BTreeMap<&String, Mark> = self
.learning_objectives
.keys()
.map(|k| (k, Mark::White))
.collect();
let mut issues = Vec::new();
for root in self.learning_objectives.keys() {
if color[root] != Mark::White {
continue;
}
let mut stack: Vec<(&String, usize)> = vec![(root, 0)];
color.insert(root, Mark::Gray);
while let Some((node, idx)) = stack.pop() {
let empty: Vec<String> = Vec::new();
let pres = self
.learning_objectives
.get(node)
.map(|o| &o.prerequisites)
.unwrap_or(&empty);
if idx < pres.len() {
stack.push((node, idx + 1));
if let Some((key, _)) = self.learning_objectives.get_key_value(&pres[idx]) {
match color[key] {
Mark::Gray => issues.push(format!(
"objective `{node}`: prerequisite cycle through `{key}`"
)),
Mark::White => {
color.insert(key, Mark::Gray);
stack.push((key, 0));
}
Mark::Black => {}
}
}
} else {
color.insert(node, Mark::Black);
}
}
}
issues
}
/// Looks up an objective, erroring on a dangling reference.
///
/// # Arguments
///
/// * `id` - the objective id.
/// * `context` - what referenced it, for the error message.
///
/// # Returns
///
/// The objective.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when the id is not registered.
pub fn objective(&self, id: &str, context: &str) -> Result<&Objective> {
self.learning_objectives
.get(id)
.ok_or_else(|| Error::Unresolved {
kind: "learning objective",
id: id.to_string(),
context: Some(context.to_string()),
})
}
/// Looks up a lecture, erroring on a dangling reference.
///
/// # Arguments
///
/// * `id` - the lecture id.
/// * `context` - what referenced it, for the error message.
///
/// # Returns
///
/// The lecture.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when the id is not registered.
pub fn lecture(&self, id: &str, context: &str) -> Result<&Lecture> {
self.lectures.get(id).ok_or_else(|| Error::Unresolved {
kind: "lecture",
id: id.to_string(),
context: Some(context.to_string()),
})
}
/// The objective text, or the bare id when unregistered.
///
/// Report rendering uses this so a missing objective degrades to a readable
/// label instead of failing a whole report.
///
/// # Arguments
///
/// * `id` - the objective id.
///
/// # Returns
///
/// The display text.
pub fn objective_text(&self, id: &str) -> String {
self.learning_objectives
.get(id)
.map(|o| o.text.clone())
.unwrap_or_else(|| id.to_string())
}
/// Objectives in a stable teaching order: by unit as declared, then by id.
///
/// # Returns
///
/// Objective ids in report order.
pub fn objectives_in_order(&self) -> Vec<String> {
let unit_rank: BTreeMap<&str, usize> = self
.units
.iter()
.enumerate()
.map(|(i, u)| (u.id.as_str(), i))
.collect();
let mut ids: Vec<&String> = self.learning_objectives.keys().collect();
ids.sort_by_key(|id| {
let lo = &self.learning_objectives[*id];
let rank = lo
.unit
.as_deref()
.and_then(|u| unit_rank.get(u).copied())
.unwrap_or(usize::MAX);
(rank, (*id).clone())
});
ids.into_iter().cloned().collect()
}
/// A skeleton course file for `coursebank init`.
///
/// # Arguments
///
/// * `code` - the course code.
/// * `title` - the course title.
/// * `term` - the term.
///
/// # Returns
///
/// A minimal but valid course file.
pub fn skeleton(code: &str, title: &str, term: &str) -> CourseFile {
let mut lectures = BTreeMap::new();
lectures.insert(
"L01".to_string(),
Lecture {
title: "Course introduction".to_string(),
date: None,
unit: Some("u-intro".to_string()),
slides_url: None,
readings: Vec::new(),
},
);
let mut los = BTreeMap::new();
los.insert(
"lo-example".to_string(),
Objective {
text: "Replace this with an objective stated as a student action.".to_string(),
unit: Some("u-intro".to_string()),
lectures: vec!["L01".to_string()],
level_ceiling: Some(Level::Understand),
prerequisites: Vec::new(),
tags: Vec::new(),
assessed: true,
},
);
CourseFile {
schema_version: SCHEMA_VERSION.to_string(),
course: Course {
code: code.to_string(),
title: title.to_string(),
term: term.to_string(),
institution: None,
instructors: Vec::new(),
slug: None,
},
policy: Policy::default(),
units: vec![Unit {
id: "u-intro".to_string(),
title: "Introduction".to_string(),
description: None,
}],
lectures,
learning_objectives: los,
stimuli: BTreeMap::new(),
}
}
}
/// The standard directory layout of a course, resolved from a root.
///
/// Keeping the layout in one type means every command agrees on where things
/// live, and a future rearrangement touches this struct only.
#[derive(Debug, Clone)]
pub struct Layout {
/// The course root.
pub root: PathBuf,
}
impl Layout {
/// Builds a layout from a course root directory.
///
/// # Arguments
///
/// * `root` - the course directory.
///
/// # Returns
///
/// The layout.
pub fn new(root: impl Into<PathBuf>) -> Layout {
Layout { root: root.into() }
}
/// Path to `course.yaml`.
pub fn course_file(&self) -> PathBuf {
self.root.join(COURSE_FILE)
}
/// Directory holding item bank YAML files.
pub fn banks(&self) -> PathBuf {
self.root.join("banks")
}
/// Directory holding assessment records.
pub fn assessments(&self) -> PathBuf {
self.root.join("assessments")
}
/// Directory holding response tables and derived statistics.
pub fn data(&self) -> PathBuf {
self.root.join("data")
}
/// Directory holding generated reports.
pub fn reports(&self) -> PathBuf {
self.root.join("reports")
}
/// Directory holding generated exports such as QTI packages.
pub fn build(&self) -> PathBuf {
self.root.join("build")
}
/// Directory holding emitted JSON Schema files for editor validation.
pub fn schema(&self) -> PathBuf {
self.root.join("schema")
}
/// Creates every directory in the layout.
///
/// # Errors
///
/// Returns [`Error::Io`] if a directory cannot be created.
pub fn create_all(&self) -> Result<()> {
for dir in [
self.root.clone(),
self.banks(),
self.assessments(),
self.data(),
self.reports(),
self.build(),
self.schema(),
] {
std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
}
Ok(())
}
}
/// Lowercases and hyphenates a string for use in file names and ids.
///
/// # Arguments
///
/// * `s` - the text to convert.
///
/// # Returns
///
/// A slug of lowercase alphanumerics separated by single hyphens.
pub fn slugify(s: &str) -> String {
let mut out = String::new();
let mut prev_dash = true;
for ch in s.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
out
}
fn default_version() -> String {
SCHEMA_VERSION.to_string()
}
fn one() -> f64 {
1.0
}
fn four() -> usize {
4
}
fn two_usize() -> usize {
2
}
fn yes() -> bool {
true
}
fn mastery_default() -> f64 {
0.75
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(src: &str) -> CourseFile {
serde_yaml_ng::from_str(src).expect("course parses")
}
#[test]
fn minimal_course_parses_with_defaults() {
let c = parse(
r#"
course:
code: BIOSC 1540
title: Computational Biology
term: Spring 2026
"#,
);
assert_eq!(c.schema_version, "1.0");
assert_eq!(c.policy.options_per_item, 4);
assert_eq!(c.course.slug(), "biosc-1540");
assert!(c.validate().is_empty());
}
#[test]
fn unquoted_schema_version_still_parses() {
// YAML reads 1.0 as a float; authors should not have to remember quotes.
let c = parse(
r#"
schema_version: 1.0
course: { code: X, title: Y, term: Z }
"#,
);
assert_eq!(c.schema_version, "1.0");
}
#[test]
fn unknown_keys_are_rejected() {
let e = serde_yaml_ng::from_str::<CourseFile>(
r#"
course: { code: X, title: Y, term: Z }
lecutres: {}
"#,
);
assert!(e.is_err(), "a misspelled top-level key must not be ignored");
}
#[test]
fn dangling_references_are_reported() {
let c = parse(
r#"
course: { code: X, title: Y, term: Z }
units:
- { id: u-a, title: A }
lectures:
L01: { title: One, unit: u-nope }
learning_objectives:
lo-a: { text: Do a thing, unit: u-a, lectures: [L99], prerequisites: [lo-missing] }
"#,
);
let issues = c.validate();
assert!(issues.iter().any(|i| i.contains("unknown unit `u-nope`")));
assert!(issues.iter().any(|i| i.contains("unknown lecture `L99`")));
assert!(issues.iter().any(|i| i.contains("lo-missing")));
}
#[test]
fn prerequisite_cycles_are_detected() {
let c = parse(
r#"
course: { code: X, title: Y, term: Z }
learning_objectives:
lo-a: { text: A, prerequisites: [lo-b] }
lo-b: { text: B, prerequisites: [lo-a] }
"#,
);
let issues = c.validate();
assert!(
issues.iter().any(|i| i.contains("cycle")),
"expected a cycle report, got {issues:?}"
);
}
#[test]
fn objectives_sort_by_declared_unit_order() {
let c = parse(
r#"
course: { code: X, title: Y, term: Z }
units:
- { id: u-second, title: Second }
- { id: u-first, title: First }
learning_objectives:
lo-z: { text: Z, unit: u-second }
lo-a: { text: A, unit: u-first }
"#,
);
// Declared order wins over alphabetical order of unit ids.
assert_eq!(c.objectives_in_order(), vec!["lo-z", "lo-a"]);
}
#[test]
fn slugify_collapses_punctuation() {
assert_eq!(slugify("BIOSC 1540"), "biosc-1540");
assert_eq!(slugify("Exam 4 -- Final!"), "exam-4-final");
assert_eq!(slugify(" "), "");
}
}
+820
View File
@@ -0,0 +1,820 @@
//! The item: one assessable question, with both the intent it was written with
//! and the evidence it has produced.
//!
//! The organizing idea is that those two things belong in one versioned place.
//! [`Design`] is written before an item is ever used and says what you predict:
//! how hard, how discriminating, how long, and what the item is meant to reveal.
//! [`Calibration`] is written by the tool afterwards and says what happened.
//! Keeping them adjacent is what turns a question bank into an instrument you
//! can improve, because every administration produces a checkable prediction.
//!
//! One deliberate departure from a naive design: [`Calibration`] is *cumulative*
//! rather than per-administration. Raw per-response data belongs in the Parquet
//! tables under `data/`, which are far better at holding it, and an item's YAML
//! holds the rolled-up estimate plus a list of which administrations went into
//! it. That keeps bank files readable and reviewable in a pull request while
//! still letting statistics accumulate across terms.
use serde::{Deserialize, Serialize};
use crate::date::Date;
use crate::hash::fingerprint;
use crate::taxonomy::{
CognitiveProcess, Discrimination, ErrorType, Flag, Format, Level, ReviewAction, Status,
};
/// One assessable question.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Item {
/// Stable id, unique within its bank, conventionally `q-<slug>-NNN`.
///
/// Ids are never reused and never renumbered: the id is the join key that
/// ties an item to every assessment it has appeared on and every response
/// row ever recorded for it.
pub id: String,
/// Revision counter, bumped whenever the content changes in a way that
/// invalidates pooled statistics.
#[serde(default = "one_u32")]
pub version: u32,
/// Workflow state; only [`Status::Approved`] items may be assembled.
pub status: Status,
/// Cognitive demand, 1 through 5.
pub level: Level,
/// The specific process the item elicits. Required for approval, and checked
/// against `level`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cognitive_process: Option<CognitiveProcess>,
/// Response format.
#[serde(default = "default_format")]
pub format: Format,
/// A bonus item, scored outside the graded total.
#[serde(default, skip_serializing_if = "is_false")]
pub bonus: bool,
/// Point value; falls back to the course policy when absent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub points: Option<f64>,
/// A short human title, used in tables and Canvas question names.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Id of a shared stimulus in the course registry, for case-based testlets.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stimulus: Option<String>,
/// The prompt.
pub stem: String,
/// The answer options in canonical order. Shuffling happens at export time
/// per form, never here, so the bank stays diffable.
pub options: Vec<Choice>,
/// Objectives this item measures, as ids into the course registry.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec<String>,
/// Where the material was taught.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<Source>,
/// Free-form topic tags, for slicing a bank by subject rather than lecture.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub topics: Vec<String>,
/// Item ids or objective ids a student needs before this is fair.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prerequisites: Vec<String>,
/// Figures or data files reproduced with the item.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<Asset>,
/// What you predicted before using it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub design: Option<Design>,
/// What the evidence says, accumulated across administrations.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub calibration: Option<Calibration>,
/// The last review decision recorded for this item.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review: Option<Review>,
/// Append-only change log.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub history: Vec<HistoryEntry>,
/// The author of record.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
/// Notes that must never reach a student.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes_private: Option<String>,
/// Set when the item was retired, with the reason.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retired: Option<Retirement>,
}
/// One answer option.
///
/// The optional fields are what separate a designed distractor from filler. An
/// option that names the [`ErrorType`] it targets and the misconception behind it
/// is one you can report on: when a third of the cohort picks it, you know what
/// they were thinking, and the student report can say so.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Choice {
/// Option letter, `A` through `H`.
pub id: String,
/// The option text.
pub text: String,
/// Whether this option is keyed correct.
#[serde(default)]
pub correct: bool,
/// Credit awarded, from 0 to 1. Absent means 1.0 when correct, else 0.0.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credit: Option<f64>,
/// Instructor-facing rationale for why this option is right or wrong.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub explanation: Option<String>,
/// The nudge you would give a student reconsidering this option.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
/// The specific wrong idea this distractor is built to capture.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub misconception: Option<String>,
/// The category of that error.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_type: Option<ErrorType>,
/// Whether a wrong option is defensible enough to earn partial credit.
#[serde(default, skip_serializing_if = "is_false")]
pub defensible: bool,
/// The argument for why it is defensible. Required whenever credit is
/// awarded to a wrong option, so partial credit is always justified in
/// writing rather than by memory.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub defense: Option<String>,
/// Text released to students after the assessment. This is what a student
/// report shows them when they chose this option.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub feedback_student: Option<String>,
/// Your a priori guess at how often this option is chosen.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selection_rate_expected: Option<f64>,
}
impl Choice {
/// The credit this option earns, resolving the default from `correct`.
///
/// # Returns
///
/// Credit in `[0, 1]`.
pub fn credit(&self) -> f64 {
self.credit.unwrap_or(if self.correct { 1.0 } else { 0.0 })
}
/// Whether this option awards credit without being keyed correct.
pub fn is_partial(&self) -> bool {
!self.correct && self.credit() > 0.0
}
/// The best available student-facing explanation of this option.
///
/// Prefers explicit student feedback, then the misconception, then the
/// instructor explanation, so a report degrades gracefully as authoring
/// completeness varies.
///
/// # Returns
///
/// The text, or `None` when the option carries no rationale at all.
pub fn student_text(&self) -> Option<&str> {
self.feedback_student
.as_deref()
.or(self.misconception.as_deref())
.or(self.explanation.as_deref())
}
}
/// Where the assessed material was taught.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Source {
/// Lecture id in the course registry.
pub lecture: String,
/// Slide numbers, so a student report can point at a page rather than a
/// whole lecture.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub slides: Vec<u32>,
/// Readings, cited however you cite them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub readings: Vec<String>,
/// A timestamp into a recording, in seconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recording_seconds: Option<u32>,
}
/// A figure or data file reproduced with an item.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Asset {
/// Path relative to the course root.
pub path: String,
/// Alt text. Required in practice for accessibility; the linter says so.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alt: Option<String>,
/// A caption printed below the figure.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
}
/// The a priori design of an item: your predictions, written down.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Design {
/// The proportion of the target cohort you expect to answer correctly.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_difficulty: Option<f64>,
/// How sharply you expect it to separate students.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_discrimination: Option<Discrimination>,
/// How long you expect it to take, in seconds. Summed over a form, this is
/// how you check that an exam fits the period.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_time_seconds: Option<f64>,
/// What the item is meant to reveal, and why it sits at its level.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rationale: Option<String>,
}
/// Accumulated evidence about an item's behavior.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Calibration {
/// The administrations pooled into these numbers.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub administrations: Vec<String>,
/// When the calibration was last recomputed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated: Option<Date>,
/// The content fingerprint these statistics describe. If it differs from the
/// item's current fingerprint, the item was edited after calibration and the
/// numbers are stale; the linter says so.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>,
/// Total examinees pooled.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub n_examinees: Option<usize>,
/// Proportion correct.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub p_value: Option<f64>,
/// Corrected item-total point-biserial correlation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub point_biserial: Option<f64>,
/// Upper-minus-lower-group discrimination index.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discrimination_index: Option<f64>,
/// Mean response time, when the platform reports it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mean_response_time_seconds: Option<f64>,
/// Proportion of responses faster than plausible reading time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rapid_guess_rate: Option<f64>,
/// Per-option behavior, keyed by option letter.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub option_stats: std::collections::BTreeMap<String, OptionStat>,
/// Fitted item response theory parameters.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub irt: Option<IrtParams>,
/// Machine-detected problems.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub flags: Vec<Flag>,
}
/// How one option behaved.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OptionStat {
/// Proportion of examinees who chose it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selection_rate: Option<f64>,
/// Correlation between choosing it and total score. Negative for a working
/// distractor; positive on a distractor is a warning.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub point_biserial: Option<f64>,
/// Selection rate among the top scoring group.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upper_group_rate: Option<f64>,
/// Selection rate among the bottom scoring group.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lower_group_rate: Option<f64>,
}
/// Fitted item response theory parameters.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IrtParams {
/// Which model was fitted.
pub model: IrtModel,
/// Discrimination.
pub a: f64,
/// Difficulty, on the same scale as ability.
pub b: f64,
/// Lower asymptote, the guessing parameter.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub c: Option<f64>,
/// Standard error of `a`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub se_a: Option<f64>,
/// Standard error of `b`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub se_b: Option<f64>,
/// Examinees the fit was based on. Small samples give unstable parameters,
/// so this travels with them rather than being looked up later.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub n: Option<usize>,
/// Whether priors were used, which matters when interpreting `a`.
#[serde(default, skip_serializing_if = "is_false")]
pub bayesian: bool,
}
/// The item response theory model family.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IrtModel {
/// One parameter: difficulty only, discrimination fixed at 1.
Rasch,
/// Two parameters: discrimination and difficulty.
#[serde(rename = "2pl")]
TwoPl,
/// Three parameters, adding a lower asymptote for guessing.
#[serde(rename = "3pl")]
ThreePl,
}
impl IrtModel {
/// The token used in YAML.
pub fn as_str(self) -> &'static str {
match self {
IrtModel::Rasch => "rasch",
IrtModel::TwoPl => "2pl",
IrtModel::ThreePl => "3pl",
}
}
}
/// A recorded review decision.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Review {
/// Who reviewed it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reviewed_by: Option<String>,
/// When.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reviewed_on: Option<Date>,
/// What was decided.
pub action: ReviewAction,
/// Why.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
/// Why and when an item left service.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Retirement {
/// When it was retired.
pub on: Date,
/// Why.
pub reason: String,
/// A replacement item id, when one exists.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replaced_by: Option<String>,
}
/// One entry in an item's change log.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HistoryEntry {
/// The version this change produced.
pub version: u32,
/// When it was made.
pub date: Date,
/// Who made it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
/// What changed.
pub change: String,
}
impl Item {
/// Builds a draft item with everything optional left empty.
///
/// A constructor rather than a `Default` implementation, because there is no
/// sensible default id, stem, or option set: an item missing any of those is
/// not a lesser item, it is not an item. Requiring them at construction means
/// the only way to get a half-built `Item` is deliberately.
///
/// The result is `Status::Draft` and deliberately will not pass
/// [`Item::is_assemblable`] — it still needs learning objectives, sources, and
/// a cognitive process before it can be drawn onto an assessment.
///
/// # Arguments
///
/// * `id` - the item id.
/// * `level` - the cognitive level.
/// * `stem` - the question.
/// * `options` - the answer options.
///
/// # Returns
///
/// The draft item.
pub fn draft(id: &str, level: Level, stem: &str, options: Vec<Choice>) -> Item {
Item {
id: id.to_string(),
version: 1,
status: Status::Draft,
level,
cognitive_process: None,
format: if options.iter().filter(|o| o.correct).count() > 1 {
Format::MultipleResponse
} else {
Format::SingleBestAnswer
},
bonus: false,
points: None,
title: None,
stimulus: None,
stem: stem.to_string(),
options,
learning_objectives: Vec::new(),
sources: Vec::new(),
topics: Vec::new(),
prerequisites: Vec::new(),
assets: Vec::new(),
design: None,
calibration: None,
review: None,
history: Vec::new(),
author: None,
notes_private: None,
retired: None,
}
}
/// Indices of the keyed-correct options.
///
/// # Returns
///
/// Zero-based indices into `options`.
pub fn key_indices(&self) -> Vec<usize> {
self.options
.iter()
.enumerate()
.filter(|(_, o)| o.correct)
.map(|(i, _)| i)
.collect()
}
/// The keyed-correct option letters, sorted.
///
/// # Returns
///
/// Letters such as `["C"]` or `["A", "B"]`.
pub fn key_letters(&self) -> Vec<String> {
let mut out: Vec<String> = self
.options
.iter()
.filter(|o| o.correct)
.map(|o| o.id.clone())
.collect();
out.sort();
out
}
/// Looks up an option by letter.
///
/// # Arguments
///
/// * `letter` - the option id, case insensitive.
///
/// # Returns
///
/// The option, or `None`.
pub fn option(&self, letter: &str) -> Option<&Choice> {
self.options
.iter()
.find(|o| o.id.eq_ignore_ascii_case(letter))
}
/// Whether the item keys more than one option.
pub fn is_multi_key(&self) -> bool {
self.key_indices().len() > 1
}
/// The display title, falling back to a truncated stem.
///
/// # Returns
///
/// A short label suitable for a table or a Canvas question name.
pub fn display_title(&self) -> String {
if let Some(t) = &self.title {
if !t.trim().is_empty() {
return t.trim().to_string();
}
}
let flat = self.stem.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() <= 60 {
flat
} else {
let head: String = flat.chars().take(57).collect();
format!("{head}...")
}
}
/// A content fingerprint over everything that affects what a student sees.
///
/// Metadata deliberately does not contribute: retagging an objective must not
/// invalidate pooled statistics, but rewording an option must.
///
/// # Returns
///
/// The fingerprint as hex.
pub fn fingerprint(&self) -> String {
let mut parts: Vec<String> = vec![self.stem.trim().to_string()];
// Canonicalize by option letter so reordering the YAML block, which does
// not change the item, does not change the fingerprint.
let mut opts: Vec<&Choice> = self.options.iter().collect();
opts.sort_by(|a, b| a.id.cmp(&b.id));
for o in opts {
parts.push(format!(
"{}|{}|{}",
o.id,
if o.correct { "1" } else { "0" },
o.text.trim()
));
}
if let Some(s) = &self.stimulus {
parts.push(format!("stimulus:{s}"));
}
fingerprint(parts.iter().map(|s| s.as_str()))
}
/// Whether the recorded calibration matches the current content.
///
/// # Returns
///
/// `false` when the item was edited after it was calibrated.
pub fn calibration_is_current(&self) -> bool {
match self
.calibration
.as_ref()
.and_then(|c| c.fingerprint.as_ref())
{
Some(fp) => *fp == self.fingerprint(),
None => true,
}
}
/// The point value, resolving against a course default.
///
/// # Arguments
///
/// * `default_points` - the course policy value.
///
/// # Returns
///
/// The point value to use.
pub fn points(&self, default_points: f64) -> f64 {
self.points.unwrap_or(default_points)
}
/// Whether this item may be placed on a graded assessment.
pub fn is_assemblable(&self) -> bool {
self.status.is_usable() && self.retired.is_none()
}
/// The expected time in seconds, falling back to a level-based estimate.
///
/// The fallbacks are rough but useful: without them, a form's total time
/// estimate silently drops every item that has no `design` block.
///
/// # Returns
///
/// Seconds.
pub fn expected_seconds(&self) -> f64 {
if let Some(t) = self.design.as_ref().and_then(|d| d.expected_time_seconds) {
return t;
}
match self.level {
Level::Remember => 35.0,
Level::Understand => 65.0,
Level::Apply => 95.0,
Level::Analyze => 130.0,
Level::Create => 165.0,
}
}
/// Appends a change-log entry and bumps the version.
///
/// # Arguments
///
/// * `change` - a description of what changed.
/// * `author` - who made the change.
pub fn record_change(&mut self, change: &str, author: Option<&str>) {
self.version += 1;
self.history.push(HistoryEntry {
version: self.version,
date: Date::today(),
author: author.map(|a| a.to_string()),
change: change.to_string(),
});
}
}
fn one_u32() -> u32 {
1
}
fn default_format() -> Format {
Format::SingleBestAnswer
}
fn is_false(b: &bool) -> bool {
!*b
}
#[cfg(test)]
mod tests {
use super::*;
fn item(src: &str) -> Item {
serde_yaml_ng::from_str(src).expect("item parses")
}
const MINIMAL: &str = r#"
id: q-demo-001
status: draft
level: 2
stem: Which statement best explains the effect?
options:
- { id: A, text: Right, correct: true }
- { id: B, text: Wrong }
- { id: C, text: Also wrong }
"#;
#[test]
fn minimal_item_parses_with_defaults() {
let it = item(MINIMAL);
assert_eq!(it.version, 1);
assert_eq!(it.format, Format::SingleBestAnswer);
assert!(!it.bonus);
assert_eq!(it.key_letters(), vec!["A"]);
assert!(!it.is_multi_key());
assert!(!it.is_assemblable(), "drafts are not assemblable");
}
#[test]
fn credit_defaults_from_correctness() {
let it = item(MINIMAL);
assert_eq!(it.option("A").unwrap().credit(), 1.0);
assert_eq!(it.option("B").unwrap().credit(), 0.0);
assert!(!it.option("B").unwrap().is_partial());
let with_partial = item(
r#"
id: q-demo-002
status: draft
level: 5
stem: s
options:
- { id: A, text: Right, correct: true }
- { id: B, text: Defensible, credit: 0.5, defensible: true, defense: because }
- { id: C, text: Wrong }
"#,
);
assert!(with_partial.option("B").unwrap().is_partial());
assert_eq!(with_partial.option("B").unwrap().credit(), 0.5);
}
#[test]
fn fingerprint_tracks_content_not_metadata() {
let base = item(MINIMAL);
let mut retagged = base.clone();
retagged.topics = vec!["kinetics".into()];
retagged.learning_objectives = vec!["lo-a".into()];
retagged.author = Some("someone".into());
assert_eq!(
base.fingerprint(),
retagged.fingerprint(),
"metadata must not invalidate pooled statistics"
);
let mut reworded = base.clone();
reworded.options[1].text = "Wrong, but differently".into();
assert_ne!(base.fingerprint(), reworded.fingerprint());
let mut rekeyed = base.clone();
rekeyed.options[0].correct = false;
rekeyed.options[1].correct = true;
assert_ne!(base.fingerprint(), rekeyed.fingerprint());
}
#[test]
fn fingerprint_ignores_yaml_option_order() {
let a = item(MINIMAL);
let b = item(
r#"
id: q-demo-001
status: draft
level: 2
stem: Which statement best explains the effect?
options:
- { id: C, text: Also wrong }
- { id: A, text: Right, correct: true }
- { id: B, text: Wrong }
"#,
);
assert_eq!(a.fingerprint(), b.fingerprint());
}
#[test]
fn stale_calibration_is_detectable() {
let mut it = item(MINIMAL);
assert!(it.calibration_is_current(), "no calibration is not stale");
let fp = it.fingerprint();
it.calibration = Some(Calibration {
fingerprint: Some(fp),
..Calibration::default()
});
assert!(it.calibration_is_current());
it.stem = "A different question entirely?".into();
assert!(!it.calibration_is_current());
}
#[test]
fn display_title_truncates_long_stems() {
let mut it = item(MINIMAL);
it.title = None;
it.stem = "word ".repeat(40);
let t = it.display_title();
assert!(t.ends_with("..."));
assert_eq!(t.chars().count(), 60);
}
#[test]
fn unknown_item_keys_are_rejected() {
let bad = serde_yaml_ng::from_str::<Item>(
r#"
id: q-demo-001
status: draft
level: 2
stem: s
steam: oops
options:
- { id: A, text: a, correct: true }
"#,
);
assert!(bad.is_err());
}
#[test]
fn record_change_bumps_version_and_logs() {
let mut it = item(MINIMAL);
it.record_change("clarified the stem", Some("Alex"));
assert_eq!(it.version, 2);
assert_eq!(it.history.len(), 1);
assert_eq!(it.history[0].version, 2);
}
#[test]
fn irt_model_tokens_round_trip() {
assert_eq!(serde_json::to_string(&IrtModel::TwoPl).unwrap(), "\"2pl\"");
assert_eq!(
serde_json::from_str::<IrtModel>("\"3pl\"").unwrap(),
IrtModel::ThreePl
);
}
}
+639
View File
@@ -0,0 +1,639 @@
//! The pedagogical vocabulary: levels, cognitive processes, error types, and
//! workflow states.
//!
//! These are enums rather than strings on purpose. A typo in
//! `cognitive_process` should fail to parse, not silently create a new category
//! that then splits your coverage report in two. Just as importantly, the
//! relation between a level and the processes that belong to it is encoded here
//! in one place, so "level 3, cognitive_process: recall" is a validation error
//! rather than a label that quietly contradicts itself.
use std::fmt;
use serde::{Deserialize, Serialize};
/// Cognitive demand, following the revised Bloom taxonomy.
///
/// Serialized as the integers 1 through 5 so YAML reads `level: 3`. The derived
/// [`Ord`] follows declaration order, which is ascending demand.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "u8", into = "u8")]
pub enum Level {
/// Retrieve a fact or definition.
Remember,
/// Construct meaning; explain, compare, classify.
Understand,
/// Carry out a procedure in a given situation.
Apply,
/// Break material apart and relate the pieces.
Analyze,
/// Judge against criteria, or assemble something new.
Create,
}
impl Level {
/// Every level in ascending order, for iteration in reports.
pub const ALL: [Level; 5] = [
Level::Remember,
Level::Understand,
Level::Apply,
Level::Analyze,
Level::Create,
];
/// The numeric code used in YAML and on printed badges.
pub fn code(self) -> u8 {
match self {
Level::Remember => 1,
Level::Understand => 2,
Level::Apply => 3,
Level::Analyze => 4,
Level::Create => 5,
}
}
/// The level for a numeric code, if it is one.
///
/// Flat storage writes `0` for an unknown level rather than an empty cell,
/// because a numeric column with holes in it is awkward in every columnar
/// format. This turns that convention back into an `Option`.
///
/// # Arguments
///
/// * `code` - the numeric code, where anything outside 1..=5 means unknown.
///
/// # Returns
///
/// The level, or `None`.
pub fn from_code(code: u8) -> Option<Level> {
match code {
1 => Some(Level::Remember),
2 => Some(Level::Understand),
3 => Some(Level::Apply),
4 => Some(Level::Analyze),
5 => Some(Level::Create),
_ => None,
}
}
/// The category name, e.g. `"Apply"`.
pub fn name(self) -> &'static str {
match self {
Level::Remember => "Remember",
Level::Understand => "Understand",
Level::Apply => "Apply",
Level::Analyze => "Analyze",
Level::Create => "Evaluate/Create",
}
}
/// A one-line description suitable for a student-facing report.
pub fn blurb(self) -> &'static str {
match self {
Level::Remember => "recalling terms, facts, and definitions",
Level::Understand => "explaining ideas in your own words",
Level::Apply => "using a procedure in a new situation",
Level::Analyze => "taking a situation apart and relating the pieces",
Level::Create => "judging alternatives or building something new",
}
}
/// The cognitive processes that belong to this level.
pub fn processes(self) -> &'static [CognitiveProcess] {
use CognitiveProcess as P;
match self {
Level::Remember => &[P::Recognize, P::Recall],
Level::Understand => &[
P::Interpret,
P::Exemplify,
P::Classify,
P::Summarize,
P::Infer,
P::Compare,
P::Explain,
],
Level::Apply => &[P::Execute, P::Implement],
Level::Analyze => &[P::Differentiate, P::Organize, P::Attribute],
Level::Create => &[P::Check, P::Critique, P::Generate, P::Plan, P::Produce],
}
}
/// Whether a process is consistent with this level.
///
/// # Arguments
///
/// * `process` - the process to check.
///
/// # Returns
///
/// `true` when the pairing is coherent.
pub fn allows(self, process: CognitiveProcess) -> bool {
self.processes().contains(&process)
}
}
impl TryFrom<u8> for Level {
type Error = String;
fn try_from(v: u8) -> Result<Level, String> {
match v {
1 => Ok(Level::Remember),
2 => Ok(Level::Understand),
3 => Ok(Level::Apply),
4 => Ok(Level::Analyze),
5 => Ok(Level::Create),
other => Err(format!("level must be 1 through 5, got {other}")),
}
}
}
impl From<Level> for u8 {
fn from(l: Level) -> u8 {
l.code()
}
}
impl fmt::Display for Level {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "L{}", self.code())
}
}
/// The specific cognitive process an item is designed to elicit.
///
/// Naming the process, not just the level, is what makes the level claim
/// checkable: it forces you to say which of the several things "Understand"
/// could mean you actually wrote.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CognitiveProcess {
/// Identify a previously encountered item.
Recognize,
/// Retrieve from long-term memory unaided.
Recall,
/// Restate in another representation.
Interpret,
/// Give an instance of a category.
Exemplify,
/// Assign an instance to a category.
Classify,
/// Abstract a general theme.
Summarize,
/// Draw a logical conclusion from given information.
Infer,
/// Detect correspondences between two things.
Compare,
/// Construct a cause-and-effect account.
Explain,
/// Apply a procedure to a familiar task.
Execute,
/// Apply a procedure to an unfamiliar task.
Implement,
/// Distinguish relevant from irrelevant parts.
Differentiate,
/// Determine how elements fit a structure.
Organize,
/// Determine a point of view or intent.
Attribute,
/// Test for internal consistency.
Check,
/// Judge against external criteria.
Critique,
/// Propose alternative hypotheses.
Generate,
/// Devise a procedure.
Plan,
/// Construct a product.
Produce,
}
impl CognitiveProcess {
/// The level this process belongs to.
pub fn level(self) -> Level {
for level in Level::ALL {
if level.allows(self) {
return level;
}
}
// Unreachable: every variant appears in exactly one level's list.
Level::Remember
}
/// Every process, in level order.
pub const ALL: [CognitiveProcess; 19] = [
CognitiveProcess::Recognize,
CognitiveProcess::Recall,
CognitiveProcess::Interpret,
CognitiveProcess::Exemplify,
CognitiveProcess::Classify,
CognitiveProcess::Summarize,
CognitiveProcess::Infer,
CognitiveProcess::Compare,
CognitiveProcess::Explain,
CognitiveProcess::Execute,
CognitiveProcess::Implement,
CognitiveProcess::Differentiate,
CognitiveProcess::Organize,
CognitiveProcess::Attribute,
CognitiveProcess::Check,
CognitiveProcess::Critique,
CognitiveProcess::Generate,
CognitiveProcess::Plan,
CognitiveProcess::Produce,
];
/// The snake_case token used in YAML.
pub fn as_str(self) -> &'static str {
use CognitiveProcess as P;
match self {
P::Recognize => "recognize",
P::Recall => "recall",
P::Interpret => "interpret",
P::Exemplify => "exemplify",
P::Classify => "classify",
P::Summarize => "summarize",
P::Infer => "infer",
P::Compare => "compare",
P::Explain => "explain",
P::Execute => "execute",
P::Implement => "implement",
P::Differentiate => "differentiate",
P::Organize => "organize",
P::Attribute => "attribute",
P::Check => "check",
P::Critique => "critique",
P::Generate => "generate",
P::Plan => "plan",
P::Produce => "produce",
}
}
}
impl fmt::Display for CognitiveProcess {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// The category of mistake a distractor is built to capture.
///
/// This does double duty. It disciplines authoring, because a distractor you
/// cannot name an error for is probably filler. And it makes item analysis
/// legible afterwards: a high selection rate on a `DroppedStep` option tells you
/// where in a procedure students slip, which a bare letter never would.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorType {
/// Confused one remembered fact for a neighboring one.
RecallConfusion,
/// Swapped two terms that sound or look alike.
TerminologySwap,
/// Holds a specific, nameable wrong model.
Misconception,
/// Knows part of the idea but not all of it.
IncompleteUnderstanding,
/// Applied a valid rule outside its scope.
Overgeneralization,
/// True but irrelevant to the question asked.
PlausibleIrrelevant,
/// Omitted a step in a procedure.
DroppedStep,
/// Inverted the direction of a relationship.
ReversedRelationship,
/// Used a procedure that does not apply here.
WrongProcedure,
/// Right method, wrong sign or order of magnitude.
SignOrMagnitudeError,
/// Ignored a condition that interacts with the answer.
IgnoresInteractingCondition,
/// Correct as far as it goes, but not the best answer.
CorrectButIncomplete,
/// Took a heuristic shortcut that usually works.
CommonShortcut,
}
impl ErrorType {
/// Every error type.
pub const ALL: [ErrorType; 13] = [
ErrorType::RecallConfusion,
ErrorType::TerminologySwap,
ErrorType::Misconception,
ErrorType::IncompleteUnderstanding,
ErrorType::Overgeneralization,
ErrorType::PlausibleIrrelevant,
ErrorType::DroppedStep,
ErrorType::ReversedRelationship,
ErrorType::WrongProcedure,
ErrorType::SignOrMagnitudeError,
ErrorType::IgnoresInteractingCondition,
ErrorType::CorrectButIncomplete,
ErrorType::CommonShortcut,
];
/// The snake_case token used in YAML.
pub fn as_str(self) -> &'static str {
use ErrorType as E;
match self {
E::RecallConfusion => "recall_confusion",
E::TerminologySwap => "terminology_swap",
E::Misconception => "misconception",
E::IncompleteUnderstanding => "incomplete_understanding",
E::Overgeneralization => "overgeneralization",
E::PlausibleIrrelevant => "plausible_irrelevant",
E::DroppedStep => "dropped_step",
E::ReversedRelationship => "reversed_relationship",
E::WrongProcedure => "wrong_procedure",
E::SignOrMagnitudeError => "sign_or_magnitude_error",
E::IgnoresInteractingCondition => "ignores_interacting_condition",
E::CorrectButIncomplete => "correct_but_incomplete",
E::CommonShortcut => "common_shortcut",
}
}
/// A short instructor-facing gloss.
pub fn gloss(self) -> &'static str {
use ErrorType as E;
match self {
E::RecallConfusion => "confused with a neighboring fact",
E::TerminologySwap => "swapped similar terms",
E::Misconception => "specific wrong model",
E::IncompleteUnderstanding => "partial grasp of the idea",
E::Overgeneralization => "applied a rule outside its scope",
E::PlausibleIrrelevant => "true but not what was asked",
E::DroppedStep => "skipped a step",
E::ReversedRelationship => "reversed the direction",
E::WrongProcedure => "used the wrong procedure",
E::SignOrMagnitudeError => "sign or magnitude slip",
E::IgnoresInteractingCondition => "ignored an interacting condition",
E::CorrectButIncomplete => "correct but not best",
E::CommonShortcut => "took a familiar shortcut",
}
}
}
/// Where an item sits in the authoring workflow.
///
/// The state gates what the validator requires. A draft may be a bare idea; an
/// approved item must be fully sourced and designed, because approval is what
/// permits it onto a graded assessment. Retired items are kept forever so the
/// bank is an append-only record of what you have asked students.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
/// Captured but not yet worked out.
Draft,
/// Written and awaiting review.
InReview,
/// Reviewed and sent back for changes.
NeedsRevision,
/// Cleared for use on a graded assessment.
Approved,
/// Withdrawn from use but retained for the record.
Retired,
}
impl Status {
/// Whether an item in this state may appear on a graded assessment.
pub fn is_usable(self) -> bool {
matches!(self, Status::Approved)
}
/// The snake_case token used in YAML.
pub fn as_str(self) -> &'static str {
match self {
Status::Draft => "draft",
Status::InReview => "in_review",
Status::NeedsRevision => "needs_revision",
Status::Approved => "approved",
Status::Retired => "retired",
}
}
}
impl fmt::Display for Status {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// The response format of an item.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Format {
/// Exactly one keyed option.
SingleBestAnswer,
/// One or more keyed options; the student must find every one.
MultipleResponse,
/// Two options, True and False.
TrueFalse,
}
impl Format {
/// 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",
}
}
}
/// How strongly an item is expected to separate strong from weak students.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Discrimination {
/// Most prepared students get it; it anchors rather than separates.
Low,
/// Separates somewhat.
Moderate,
/// Expected to separate sharply.
High,
}
impl Discrimination {
/// The point-biserial band this expectation implies, as `(low, high)`.
///
/// Used to check an a priori expectation against the observed statistic.
pub fn expected_band(self) -> (f64, f64) {
match self {
Discrimination::Low => (-1.0, 0.20),
Discrimination::Moderate => (0.15, 0.40),
Discrimination::High => (0.30, 1.0),
}
}
}
/// What was done about an item after reviewing its statistics.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewAction {
/// Behaved as intended; leave it alone.
Keep,
/// Rewrite before reusing.
Revise,
/// Credit a defensible distractor for this administration.
AwardPartialCredit,
/// The key was wrong; fix it and rescore.
CorrectKey,
/// Withdraw from use.
Retire,
/// Keep but watch on the next administration.
Monitor,
}
/// A machine-detected problem with an item's observed behavior.
///
/// These are written by analysis, not by hand, and they are the queue you work
/// through when deciding what to revise.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Flag {
/// Weaker students outperformed stronger ones. Almost always a keying error
/// or a genuinely ambiguous stem.
NegativeDiscrimination,
/// Barely separates students.
LowDiscrimination,
/// A distractor correlates with total score better than the key does.
DistractorOutperformsKey,
/// Strong students chose one particular distractor at a high rate, which is
/// the signature of a second defensible reading.
KeyUnderperforms,
/// Nearly everyone answered correctly; carries little information.
TooEasy,
/// Nearly everyone answered incorrectly and it did not discriminate.
TooHard,
/// Many responses were faster than plausible reading time.
HighRapidGuess,
/// A distractor almost nobody chose; it is doing no work.
NonfunctioningDistractor,
/// Performance differed across groups after conditioning on ability.
DifFlagged,
/// Marked ambiguous by hand or inferred from partial credit awarded to a
/// distractor during grading.
Ambiguous,
/// The observed difficulty was far from the difficulty you predicted.
DesignMismatch,
}
impl Flag {
/// Every flag.
pub const ALL: [Flag; 11] = [
Flag::NegativeDiscrimination,
Flag::LowDiscrimination,
Flag::DistractorOutperformsKey,
Flag::KeyUnderperforms,
Flag::TooEasy,
Flag::TooHard,
Flag::HighRapidGuess,
Flag::NonfunctioningDistractor,
Flag::DifFlagged,
Flag::Ambiguous,
Flag::DesignMismatch,
];
/// The flag's stable code, matching its YAML spelling.
pub fn as_str(self) -> &'static str {
match self {
Flag::NegativeDiscrimination => "negative_discrimination",
Flag::LowDiscrimination => "low_discrimination",
Flag::DistractorOutperformsKey => "distractor_outperforms_key",
Flag::KeyUnderperforms => "key_underperforms",
Flag::TooEasy => "too_easy",
Flag::TooHard => "too_hard",
Flag::HighRapidGuess => "high_rapid_guess",
Flag::NonfunctioningDistractor => "nonfunctioning_distractor",
Flag::DifFlagged => "dif_flagged",
Flag::Ambiguous => "ambiguous",
Flag::DesignMismatch => "design_mismatch",
}
}
/// Whether the flag should stop an item from being reused as written.
///
/// Distinguishing blocking from advisory flags is what turns analysis into a
/// workflow: a negative discrimination is a keying bug to fix before the item
/// is ever given again, while an easy item is merely uninformative.
///
/// # Returns
///
/// `true` for flags that demand a revision.
pub fn is_blocking(self) -> bool {
matches!(
self,
Flag::NegativeDiscrimination
| Flag::DistractorOutperformsKey
| Flag::Ambiguous
| Flag::KeyUnderperforms
)
}
/// A short explanation of what the flag means and what to do about it.
pub fn advice(self) -> &'static str {
match self {
Flag::NegativeDiscrimination => {
"check the key first, then the stem for a second valid reading"
}
Flag::LowDiscrimination => "expected for anchors; investigate if the level is 3+",
Flag::DistractorOutperformsKey => "the distractor may be the better answer",
Flag::KeyUnderperforms => "strong students split; look for an ambiguity",
Flag::TooEasy => "fine as an anchor, wasteful if you meant it to discriminate",
Flag::TooHard => "check for a missing prerequisite or an unclear stem",
Flag::HighRapidGuess => "position on the form or time pressure, not the item",
Flag::NonfunctioningDistractor => "replace it with a plausible error",
Flag::DifFlagged => "inspect wording for content unrelated to the objective",
Flag::Ambiguous => "rewrite the stem to exclude the second reading",
Flag::DesignMismatch => "update your expectation or revise the item",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levels_order_by_demand() {
assert!(Level::Remember < Level::Create);
assert_eq!(Level::Apply.code(), 3);
}
#[test]
fn every_process_belongs_to_exactly_one_level() {
let mut seen = Vec::new();
for level in Level::ALL {
for p in level.processes() {
assert!(!seen.contains(p), "{p} appears under two levels");
seen.push(*p);
assert_eq!(p.level(), level);
}
}
assert_eq!(seen.len(), 19, "all processes are assigned");
}
#[test]
fn level_process_pairing_is_checked() {
assert!(Level::Apply.allows(CognitiveProcess::Implement));
assert!(!Level::Apply.allows(CognitiveProcess::Recall));
}
#[test]
fn level_serializes_as_an_integer() {
assert_eq!(serde_json::to_string(&Level::Apply).unwrap(), "3");
assert_eq!(serde_json::from_str::<Level>("4").unwrap(), Level::Analyze);
assert!(serde_json::from_str::<Level>("6").is_err());
assert!(serde_json::from_str::<Level>("0").is_err());
}
#[test]
fn processes_use_snake_case() {
assert_eq!(
serde_json::to_string(&CognitiveProcess::Implement).unwrap(),
"\"implement\""
);
assert_eq!(
serde_json::from_str::<Status>("\"in_review\"").unwrap(),
Status::InReview
);
}
}
+13
View File
@@ -0,0 +1,13 @@
//! Small self-contained utilities with no knowledge of courses or assessments.
//!
//! Everything here exists because pulling in a crate for it was the worse trade.
//! Each module is a few hundred lines of well-understood algorithm, and each
//! replaces a dependency that would otherwise need to keep working for as long as
//! a course repository needs to stay readable.
pub mod date;
pub mod hash;
pub mod markup;
pub mod rng;
pub mod yaml;
pub mod zipfile;
+248
View File
@@ -0,0 +1,248 @@
//! A minimal calendar date, serialized as `YYYY-MM-DD`.
//!
//! Course data is full of dates: when a lecture ran, when an item was authored,
//! when an exam was administered. Those dates need to sort, subtract, and round
//! trip through YAML exactly as written, but they never need time zones or
//! clock time. That is a small enough job to do without a dependency, so this
//! module implements it directly on top of the proleptic Gregorian calendar.
//!
//! The derived [`Ord`] is chronological because the fields are declared
//! most-significant first.
use std::fmt;
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::{Error, Result};
/// A calendar date with no time or zone.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Date {
/// Proleptic Gregorian year.
pub year: i32,
/// Month, 1 through 12.
pub month: u32,
/// Day of month, 1 through the length of the month.
pub day: u32,
}
impl Date {
/// Builds a date, checking that it exists on the calendar.
///
/// # Arguments
///
/// * `year` - the proleptic Gregorian year.
/// * `month` - month, 1 through 12.
/// * `day` - day of month.
///
/// # Returns
///
/// The date.
///
/// # Errors
///
/// Returns [`Error::BadDate`] when the month or day is out of range,
/// including February 30 and non-leap February 29.
pub fn new(year: i32, month: u32, day: u32) -> Result<Date> {
if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
return Err(Error::BadDate(format!("{year:04}-{month:02}-{day:02}")));
}
Ok(Date { year, month, day })
}
/// Today's date in UTC, read from the system clock.
///
/// UTC rather than local time keeps the value reproducible on any machine
/// that touches the course repository, which matters because these dates
/// end up committed.
///
/// # Returns
///
/// Today's date, or 1970-01-01 if the clock is set before the epoch.
pub fn today() -> Date {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
Date::from_days_since_epoch(secs.div_euclid(86_400))
}
/// Days since 1970-01-01, negative before it.
///
/// Uses Howard Hinnant's `days_from_civil`, which is exact for the whole
/// proleptic Gregorian range.
///
/// # Returns
///
/// The signed day count.
pub fn days_since_epoch(&self) -> i64 {
let y = if self.month <= 2 {
self.year as i64 - 1
} else {
self.year as i64
};
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let m = self.month as i64;
let d = self.day as i64;
let mp = if m > 2 { m - 3 } else { m + 9 };
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
/// The inverse of [`Date::days_since_epoch`].
///
/// # Arguments
///
/// * `z` - days since 1970-01-01.
///
/// # Returns
///
/// The corresponding date.
pub fn from_days_since_epoch(z: i64) -> Date {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
Date {
year: (if m <= 2 { y + 1 } else { y }) as i32,
month: m as u32,
day: d as u32,
}
}
/// Whole days from `self` to `other`, positive when `other` is later.
///
/// # Arguments
///
/// * `other` - the date to measure to.
///
/// # Returns
///
/// The signed difference in days.
pub fn days_until(&self, other: Date) -> i64 {
other.days_since_epoch() - self.days_since_epoch()
}
}
/// Length of a month, accounting for leap years.
fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap(year) => 29,
2 => 28,
_ => 0,
}
}
/// Whether a proleptic Gregorian year is a leap year.
fn is_leap(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
impl fmt::Display for Date {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
}
impl FromStr for Date {
type Err = Error;
fn from_str(s: &str) -> Result<Date> {
let t = s.trim();
let parts: Vec<&str> = t.split('-').collect();
if parts.len() != 3 {
return Err(Error::BadDate(t.to_string()));
}
let year: i32 = parts[0]
.parse()
.map_err(|_| Error::BadDate(t.to_string()))?;
let month: u32 = parts[1]
.parse()
.map_err(|_| Error::BadDate(t.to_string()))?;
let day: u32 = parts[2]
.parse()
.map_err(|_| Error::BadDate(t.to_string()))?;
Date::new(year, month, day)
}
}
impl Serialize for Date {
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Date {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Date, D::Error> {
struct V;
impl<'a> Visitor<'a> for V {
type Value = Date;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a date in YYYY-MM-DD form")
}
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Date, E> {
v.parse::<Date>().map_err(de::Error::custom)
}
}
d.deserialize_str(V)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_through_string() {
let d: Date = "2026-04-23".parse().unwrap();
assert_eq!(d.year, 2026);
assert_eq!(d.month, 4);
assert_eq!(d.day, 23);
assert_eq!(d.to_string(), "2026-04-23");
}
#[test]
fn epoch_and_back() {
for iso in ["1970-01-01", "2000-02-29", "2026-04-23", "1969-12-31"] {
let d: Date = iso.parse().unwrap();
assert_eq!(
Date::from_days_since_epoch(d.days_since_epoch()),
d,
"{iso}"
);
}
assert_eq!("1970-01-01".parse::<Date>().unwrap().days_since_epoch(), 0);
}
#[test]
fn rejects_impossible_dates() {
assert!("2026-02-30".parse::<Date>().is_err());
assert!("2025-02-29".parse::<Date>().is_err());
assert!("2024-02-29".parse::<Date>().is_ok());
assert!("2026-13-01".parse::<Date>().is_err());
assert!("2026-4-23-1".parse::<Date>().is_err());
}
#[test]
fn orders_chronologically() {
let a: Date = "2025-12-31".parse().unwrap();
let b: Date = "2026-01-01".parse().unwrap();
assert!(a < b);
assert_eq!(a.days_until(b), 1);
assert_eq!(b.days_until(a), -1);
}
}
+253
View File
@@ -0,0 +1,253 @@
//! Content fingerprints and student pseudonyms.
//!
//! Two different jobs need two different hashes, and conflating them would be a
//! privacy bug.
//!
//! [`fingerprint`] answers "is this the same question I used last time?". It
//! only needs to be stable and short, so it uses FNV-1a. It is not a security
//! primitive and is never applied to anything about a person.
//!
//! [`pseudonym`] answers "can I keep a response table under version control
//! without publishing who answered what?". Student identifiers are low entropy
//! (a seven-digit number is a ten-million-item dictionary), so an unkeyed hash
//! of one is trivially reversible and would provide no protection at all. It
//! therefore uses HMAC-SHA-256 under a secret salt that lives outside the
//! repository. Both primitives are implemented here so the crate needs no
//! cryptography dependency.
/// FNV-1a 64-bit offset basis.
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
/// FNV-1a 64-bit prime.
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
/// A short, stable content fingerprint rendered as 16 lowercase hex digits.
///
/// Used to detect that a question was silently edited between two
/// administrations, which invalidates pooling their statistics.
///
/// # Arguments
///
/// * `parts` - the canonical content pieces, hashed in order with a separator
/// so that reordering or regrouping them changes the result.
///
/// # Returns
///
/// The fingerprint as a hex string.
pub fn fingerprint<'a, I>(parts: I) -> String
where
I: IntoIterator<Item = &'a str>,
{
let mut h = FNV_OFFSET;
for part in parts {
for b in part.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(FNV_PRIME);
}
// A byte that cannot appear in the inputs, so concatenation is unambiguous.
h ^= 0x1f;
h = h.wrapping_mul(FNV_PRIME);
}
format!("{h:016x}")
}
/// A keyed pseudonym for a student identifier.
///
/// # Arguments
///
/// * `salt` - a secret of at least 16 bytes, kept out of version control.
/// * `id` - the institutional identifier or email to replace.
/// * `len` - how many hex characters to keep; 16 gives a 64-bit tag, which is
/// ample for a cohort and short enough to read in a table.
///
/// # Returns
///
/// The truncated hex tag, prefixed with `s-`.
pub fn pseudonym(salt: &[u8], id: &str, len: usize) -> String {
let mac = hmac_sha256(salt, id.trim().to_lowercase().as_bytes());
let hex: String = mac.iter().map(|b| format!("{b:02x}")).collect();
format!("s-{}", &hex[..len.min(hex.len())])
}
/// HMAC-SHA-256.
///
/// # Arguments
///
/// * `key` - the secret key, of any length.
/// * `msg` - the message to authenticate.
///
/// # Returns
///
/// The 32-byte tag.
pub fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
const BLOCK: usize = 64;
let mut k = [0u8; BLOCK];
if key.len() > BLOCK {
k[..32].copy_from_slice(&sha256(key));
} else {
k[..key.len()].copy_from_slice(key);
}
let mut inner = Vec::with_capacity(BLOCK + msg.len());
let mut outer = Vec::with_capacity(BLOCK + 32);
for b in k.iter() {
inner.push(b ^ 0x36);
outer.push(b ^ 0x5c);
}
inner.extend_from_slice(msg);
outer.extend_from_slice(&sha256(&inner));
sha256(&outer)
}
/// SHA-256 round constants.
#[rustfmt::skip]
const K: [u32; 64] = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
];
/// SHA-256 of a byte slice.
///
/// # Arguments
///
/// * `msg` - the message to digest.
///
/// # Returns
///
/// The 32-byte digest.
pub fn sha256(msg: &[u8]) -> [u8; 32] {
let mut h: [u32; 8] = [
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
0x5be0cd19,
];
// Pad to a multiple of 64 bytes: 0x80, zeros, then the 64-bit bit length.
let mut data = msg.to_vec();
let bit_len = (msg.len() as u64).wrapping_mul(8);
data.push(0x80);
while data.len() % 64 != 56 {
data.push(0);
}
data.extend_from_slice(&bit_len.to_be_bytes());
let mut w = [0u32; 64];
for chunk in data.chunks(64) {
for i in 0..16 {
let j = i * 4;
w[i] = u32::from_be_bytes([chunk[j], chunk[j + 1], chunk[j + 2], chunk[j + 3]]);
}
for i in 16..64 {
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
w[i] = w[i - 16]
.wrapping_add(s0)
.wrapping_add(w[i - 7])
.wrapping_add(s1);
}
let mut v = h;
for i in 0..64 {
let s1 = v[4].rotate_right(6) ^ v[4].rotate_right(11) ^ v[4].rotate_right(25);
let ch = (v[4] & v[5]) ^ ((!v[4]) & v[6]);
let t1 = v[7]
.wrapping_add(s1)
.wrapping_add(ch)
.wrapping_add(K[i])
.wrapping_add(w[i]);
let s0 = v[0].rotate_right(2) ^ v[0].rotate_right(13) ^ v[0].rotate_right(22);
let maj = (v[0] & v[1]) ^ (v[0] & v[2]) ^ (v[1] & v[2]);
let t2 = s0.wrapping_add(maj);
v[7] = v[6];
v[6] = v[5];
v[5] = v[4];
v[4] = v[3].wrapping_add(t1);
v[3] = v[2];
v[2] = v[1];
v[1] = v[0];
v[0] = t1.wrapping_add(t2);
}
for i in 0..8 {
h[i] = h[i].wrapping_add(v[i]);
}
}
let mut out = [0u8; 32];
for i in 0..8 {
out[i * 4..i * 4 + 4].copy_from_slice(&h[i].to_be_bytes());
}
out
}
/// Renders bytes as lowercase hex.
///
/// # Arguments
///
/// * `bytes` - the bytes to render.
///
/// # Returns
///
/// The hex string.
pub fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_matches_known_vectors() {
assert_eq!(
hex(&sha256(b"")),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
hex(&sha256(b"abc")),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
// Longer than one block, to exercise the multi-chunk path.
assert_eq!(
hex(&sha256(
b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"
)),
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
);
}
#[test]
fn hmac_matches_rfc4231_case_2() {
// RFC 4231 test case 2: key "Jefe", data "what do ya want for nothing?".
assert_eq!(
hex(&hmac_sha256(b"Jefe", b"what do ya want for nothing?")),
"5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
);
}
#[test]
fn fingerprint_is_order_sensitive_and_unambiguous() {
assert_ne!(fingerprint(["a", "b"]), fingerprint(["b", "a"]));
// Separator prevents "ab" + "c" colliding with "a" + "bc".
assert_ne!(fingerprint(["ab", "c"]), fingerprint(["a", "bc"]));
assert_eq!(fingerprint(["a", "b"]), fingerprint(["a", "b"]));
assert_eq!(fingerprint(["x"]).len(), 16);
}
#[test]
fn pseudonym_is_keyed_and_normalized() {
let a = pseudonym(b"salt-one-0123456", "4496395", 16);
let b = pseudonym(b"salt-two-0123456", "4496395", 16);
assert_ne!(a, b, "different salts must give different pseudonyms");
assert_eq!(
pseudonym(b"salt-one-0123456", " SCD62@pitt.edu ", 16),
pseudonym(b"salt-one-0123456", "scd62@pitt.edu", 16)
);
assert!(a.starts_with("s-"));
assert_eq!(a.len(), 18);
}
}
+378
View File
@@ -0,0 +1,378 @@
//! Converting the authoring markup into HTML, plain text, and Typst.
//!
//! Stems are written in a small markup that is a subset of Typst with a few
//! Markdown conveniences, because chemistry and biology questions need
//! subscripts, arrows, and Greek letters, and typing HTML entities into YAML by
//! hand is miserable.
//!
//! There is no regular expression engine here. Every rule is a scan, which keeps
//! the dependency list short and makes the escaping order explicit: HTML is
//! escaped *first*, then symbol substitutions run, so that a substitution
//! producing `&rarr;` is not itself escaped into `&amp;rarr;`.
/// Symbol substitutions, applied after HTML escaping.
///
/// Ordered longest-first within each family so `#sym.arrow.r` is not consumed by
/// a shorter prefix.
const SYMBOLS: &[(&str, &str, &str)] = &[
// (source token, html, plain text)
("#sym.gt.eq", "&ge;", "\u{2265}"),
("#sym.lt.eq", "&le;", "\u{2264}"),
("#sym.eq.not", "&ne;", "\u{2260}"),
("#sym.plus.minus", "&plusmn;", "\u{00b1}"),
("#sym.arrow.r", "&rarr;", "\u{2192}"),
("#sym.arrow.l", "&larr;", "\u{2190}"),
("#sym.arrow.lr", "&harr;", "\u{2194}"),
("#sym.rightarrow", "&rarr;", "\u{2192}"),
("#sym.leftarrow", "&larr;", "\u{2190}"),
("#sym.times", "&times;", "\u{00d7}"),
("#sym.dot", "&middot;", "\u{00b7}"),
("#sym.degree", "&deg;", "\u{00b0}"),
("#sym.infinity", "&infin;", "\u{221e}"),
("#sym.approx", "&asymp;", "\u{2248}"),
("#sym.alpha", "&alpha;", "\u{03b1}"),
("#sym.beta", "&beta;", "\u{03b2}"),
("#sym.gamma", "&gamma;", "\u{03b3}"),
("#sym.delta.cap", "&Delta;", "\u{0394}"),
("#sym.delta", "&delta;", "\u{03b4}"),
("#sym.epsilon", "&epsilon;", "\u{03b5}"),
("#sym.lambda", "&lambda;", "\u{03bb}"),
("#sym.mu", "&mu;", "\u{03bc}"),
("#sym.pi", "&pi;", "\u{03c0}"),
("#sym.sigma", "&sigma;", "\u{03c3}"),
("#sym.tau", "&tau;", "\u{03c4}"),
("#sym.phi", "&phi;", "\u{03c6}"),
("#sym.omega", "&omega;", "\u{03c9}"),
];
/// Converts authoring markup to an HTML fragment.
///
/// Handles paragraphs, bold, italic, inline code, subscripts, superscripts, and
/// the symbol table. Anything unrecognized passes through escaped, so a stray
/// `<script>` in a stem cannot become markup in a Canvas quiz.
///
/// # Arguments
///
/// * `src` - the authoring source.
///
/// # Returns
///
/// An HTML fragment, with each paragraph wrapped in `<p>`.
pub fn to_html(src: &str) -> String {
let escaped = escape_html(src);
let symbolized = apply_symbols(&escaped, true);
let inline = apply_inline(&symbolized);
let paragraphs: Vec<String> = inline
.split("\n\n")
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(|p| {
let joined = p
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
format!("<p>{joined}</p>")
})
.collect();
if paragraphs.is_empty() {
String::new()
} else {
paragraphs.join("\n")
}
}
/// Converts authoring markup to plain text.
///
/// Used for CSV columns, terminal output, and any place a fragment of HTML would
/// be noise.
///
/// # Arguments
///
/// * `src` - the authoring source.
///
/// # Returns
///
/// Plain text with markup removed and symbols rendered as Unicode.
pub fn to_plain(src: &str) -> String {
let symbolized = apply_symbols(src, false);
let mut out = strip_inline(&symbolized);
out = out
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
out.trim().to_string()
}
/// Passes authoring markup through for Typst.
///
/// The markup is already a Typst subset, so this only normalizes whitespace and
/// escapes the few characters Typst treats specially in content mode.
///
/// # Arguments
///
/// * `src` - the authoring source.
///
/// # Returns
///
/// Typst content-mode markup.
pub fn to_typst(src: &str) -> String {
let mut out = String::with_capacity(src.len());
for ch in src.trim().chars() {
match ch {
// A bare `@` or `<` starts a Typst reference or label.
'@' => out.push_str("\\@"),
'<' => out.push_str("\\<"),
'>' => out.push_str("\\>"),
_ => out.push(ch),
}
}
out
}
/// Escapes the five XML-significant characters.
///
/// # Arguments
///
/// * `s` - the text to escape.
///
/// # Returns
///
/// The escaped text.
pub fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(ch),
}
}
out
}
/// Applies the symbol table.
///
/// # Arguments
///
/// * `s` - the text.
/// * `html` - whether to emit HTML entities rather than Unicode.
///
/// # Returns
///
/// The substituted text.
fn apply_symbols(s: &str, html: bool) -> String {
let mut out = s.to_string();
for (token, entity, plain) in SYMBOLS {
if out.contains(token) {
out = out.replace(token, if html { entity } else { plain });
}
}
out
}
/// Applies inline markup rules, producing HTML.
///
/// # Arguments
///
/// * `s` - escaped, symbol-substituted text.
///
/// # Returns
///
/// The text with inline markup converted.
fn apply_inline(s: &str) -> String {
let mut out = s.to_string();
// Bracketed forms first: their contents may contain other markup characters.
out = wrap_bracket(&out, "#sub[", "<sub>", "</sub>");
out = wrap_bracket(&out, "#sup[", "<sup>", "</sup>");
out = wrap_delimited(&out, "`", "<code>", "</code>");
out = wrap_delimited(&out, "**", "<strong>", "</strong>");
out = wrap_delimited(&out, "*", "<em>", "</em>");
out = wrap_delimited(&out, "_", "<em>", "</em>");
out
}
/// Removes inline markup without replacing it.
///
/// # Arguments
///
/// * `s` - the text.
///
/// # Returns
///
/// The text with markup delimiters stripped.
fn strip_inline(s: &str) -> String {
let mut out = s.to_string();
out = wrap_bracket(&out, "#sub[", "", "");
out = wrap_bracket(&out, "#sup[", "", "");
out = wrap_delimited(&out, "`", "", "");
out = wrap_delimited(&out, "**", "", "");
out = wrap_delimited(&out, "*", "", "");
out = wrap_delimited(&out, "_", "", "");
out
}
/// Replaces `open...]` spans with wrapped content.
///
/// # Arguments
///
/// * `s` - the text.
/// * `open` - the opening token, e.g. `"#sub["`.
/// * `pre` - text to emit before the content.
/// * `post` - text to emit after the content.
///
/// # Returns
///
/// The rewritten text. Unclosed spans are left alone.
fn wrap_bracket(s: &str, open: &str, pre: &str, post: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
loop {
match rest.find(open) {
None => {
out.push_str(rest);
return out;
}
Some(i) => {
let after = &rest[i + open.len()..];
match after.find(']') {
None => {
out.push_str(rest);
return out;
}
Some(j) => {
out.push_str(&rest[..i]);
out.push_str(pre);
out.push_str(&after[..j]);
out.push_str(post);
rest = &after[j + 1..];
}
}
}
}
}
}
/// Replaces paired `delim...delim` spans with wrapped content.
///
/// A delimiter with no partner is emitted literally, so an apostrophe-heavy stem
/// or a lone asterisk does not swallow the rest of the text.
///
/// # Arguments
///
/// * `s` - the text.
/// * `delim` - the delimiter, e.g. `"**"`.
/// * `pre` - text to emit before the content.
/// * `post` - text to emit after the content.
///
/// # Returns
///
/// The rewritten text.
fn wrap_delimited(s: &str, delim: &str, pre: &str, post: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
loop {
match rest.find(delim) {
None => {
out.push_str(rest);
return out;
}
Some(i) => {
let after = &rest[i + delim.len()..];
match after.find(delim) {
None => {
out.push_str(rest);
return out;
}
Some(j) if j == 0 => {
// Empty span such as `**`; emit literally and move on.
out.push_str(&rest[..i + delim.len()]);
rest = after;
}
Some(j) => {
out.push_str(&rest[..i]);
out.push_str(pre);
out.push_str(&after[..j]);
out.push_str(post);
rest = &after[j + delim.len()..];
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escapes_before_substituting() {
// The entity produced by the symbol table must survive escaping.
assert_eq!(to_html("a #sym.arrow.r b"), "<p>a &rarr; b</p>");
// A literal ampersand is escaped.
assert_eq!(to_html("Tris & HCl"), "<p>Tris &amp; HCl</p>");
}
#[test]
fn refuses_to_pass_through_html() {
let out = to_html("<script>alert(1)</script>");
assert!(!out.contains("<script>"));
assert!(out.contains("&lt;script&gt;"));
}
#[test]
fn converts_inline_markup() {
assert_eq!(to_html("**bold**"), "<p><strong>bold</strong></p>");
assert_eq!(to_html("*em*"), "<p><em>em</em></p>");
assert_eq!(to_html("`code`"), "<p><code>code</code></p>");
assert_eq!(to_html("H#sub[2]O"), "<p>H<sub>2</sub>O</p>");
assert_eq!(to_html("x#sup[2]"), "<p>x<sup>2</sup></p>");
}
#[test]
fn bold_wins_over_italic() {
assert_eq!(
to_html("**strong** and *weak*"),
"<p><strong>strong</strong> and <em>weak</em></p>"
);
}
#[test]
fn unpaired_delimiters_are_literal() {
assert_eq!(to_html("2 * 3 = 6"), "<p>2 * 3 = 6</p>");
assert_eq!(to_html("a_b"), "<p>a_b</p>");
}
#[test]
fn splits_paragraphs_and_joins_wrapped_lines() {
let out = to_html("first line\ncontinued\n\nsecond paragraph");
assert_eq!(out, "<p>first line continued</p>\n<p>second paragraph</p>");
}
#[test]
fn empty_input_yields_empty_output() {
assert_eq!(to_html(" \n "), "");
assert_eq!(to_plain(""), "");
}
#[test]
fn plain_text_uses_unicode_and_drops_markup() {
assert_eq!(to_plain("K#sub[m] #sym.approx 5 mM"), "Km \u{2248} 5 mM");
assert_eq!(to_plain("**bold** text"), "bold text");
}
#[test]
fn typst_escapes_reference_starters() {
assert_eq!(to_typst("a @ b"), "a \\@ b");
assert_eq!(to_typst("x < y"), "x \\< y");
}
}
+169
View File
@@ -0,0 +1,169 @@
//! A small deterministic random number generator.
//!
//! Every random choice this tool makes must be reproducible: if you regenerate
//! form B of an exam a month later, it has to come out identically, or the
//! answer key you already printed is wrong. So there is no system entropy
//! anywhere in the crate. Seeds are explicit, and a seed can be derived from a
//! string like `"exam-4-2026s/form-B"` so the caller never has to invent one.
//!
//! The generator is SplitMix64: two lines of arithmetic, excellent statistical
//! properties for shuffling, and identical output on every platform.
/// A seeded SplitMix64 generator.
#[derive(Debug, Clone)]
pub struct Rng {
state: u64,
}
impl Rng {
/// Creates a generator from a numeric seed.
///
/// # Arguments
///
/// * `seed` - any value; every seed gives a distinct stream.
///
/// # Returns
///
/// The generator.
pub fn new(seed: u64) -> Rng {
Rng { state: seed }
}
/// Creates a generator from a label, so callers can seed on meaning.
///
/// # Arguments
///
/// * `label` - a stable string such as an assessment id plus a form id.
///
/// # Returns
///
/// The generator.
pub fn from_label(label: &str) -> Rng {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in label.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
Rng::new(h)
}
/// The next 64 random bits.
///
/// # Returns
///
/// A uniformly distributed `u64`.
pub fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
/// A uniform integer in `[0, n)`.
///
/// Rejection sampling removes the modulo bias, which matters because a
/// biased shuffle would systematically favor certain answer positions.
///
/// # Arguments
///
/// * `n` - the exclusive upper bound; returns 0 when `n` is 0.
///
/// # Returns
///
/// The sampled integer.
pub fn below(&mut self, n: u64) -> u64 {
if n == 0 {
return 0;
}
let zone = u64::MAX - (u64::MAX % n) - 1;
loop {
let x = self.next_u64();
if x <= zone {
return x % n;
}
}
}
/// A uniform float in `[0, 1)`.
///
/// # Returns
///
/// The sampled float.
pub fn unit(&mut self) -> f64 {
// 53 bits of mantissa is the whole precision of f64.
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
/// Shuffles a slice in place with a Fisher-Yates pass.
///
/// # Arguments
///
/// * `items` - the slice to permute.
pub fn shuffle<T>(&mut self, items: &mut [T]) {
if items.len() < 2 {
return;
}
for i in (1..items.len()).rev() {
let j = self.below(i as u64 + 1) as usize;
items.swap(i, j);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn same_seed_gives_same_stream() {
let a: Vec<u64> = (0..8).map(|_| Rng::new(42).next_u64()).collect();
assert!(a.iter().all(|x| *x == a[0]), "fresh generators agree");
let mut r1 = Rng::new(7);
let mut r2 = Rng::new(7);
for _ in 0..64 {
assert_eq!(r1.next_u64(), r2.next_u64());
}
}
#[test]
fn different_labels_diverge() {
let mut a = Rng::from_label("exam-4/form-A");
let mut b = Rng::from_label("exam-4/form-B");
assert_ne!(a.next_u64(), b.next_u64());
}
#[test]
fn shuffle_is_a_permutation_and_reproducible() {
let mut v: Vec<u32> = (0..50).collect();
let mut w = v.clone();
Rng::from_label("seed").shuffle(&mut v);
Rng::from_label("seed").shuffle(&mut w);
assert_eq!(v, w, "same label reproduces the same order");
let mut sorted = v.clone();
sorted.sort_unstable();
assert_eq!(sorted, (0..50).collect::<Vec<u32>>());
assert_ne!(v, sorted, "50 elements should not shuffle back to sorted");
}
#[test]
fn below_stays_in_range() {
let mut r = Rng::new(1);
for _ in 0..1000 {
assert!(r.below(5) < 5);
}
assert_eq!(r.below(1), 0);
assert_eq!(r.below(0), 0);
}
#[test]
fn unit_is_in_the_unit_interval() {
let mut r = Rng::new(3);
for _ in 0..1000 {
let u = r.unit();
assert!((0.0..1.0).contains(&u));
}
}
}
+224
View File
@@ -0,0 +1,224 @@
//! Reading and writing the YAML and JSON files the tool owns.
//!
//! Two small conveniences live here. First, every read and write attaches the
//! path to its error, because "invalid type: found string" is useless without
//! knowing which of forty bank files produced it. Second, [`flexible_string`]
//! lets `schema_version: 1.0` parse as the string `"1.0"`. YAML reads an
//! unquoted `1.0` as a float, and being told to go back and add quotation marks
//! is a poor first experience of a schema.
use std::fmt;
use std::fs;
use std::path::Path;
use serde::de::{self, DeserializeOwned, Visitor};
use serde::{Deserializer, Serialize};
use crate::error::{Error, Result};
/// Deserializes a YAML file into any schema type.
///
/// # Arguments
///
/// * `path` - the file to read.
///
/// # Returns
///
/// The deserialized value.
///
/// # Errors
///
/// Returns [`Error::Io`] when the file cannot be read and [`Error::Yaml`] when
/// it does not match the target schema.
pub fn read<T: DeserializeOwned>(path: &Path) -> Result<T> {
let text = fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
serde_yaml_ng::from_str(&text).map_err(|source| Error::Yaml {
path: path.to_path_buf(),
source,
})
}
/// Serializes a value to a YAML file, creating parent directories as needed.
///
/// # Arguments
///
/// * `path` - the destination file.
/// * `value` - the value to write.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure, or [`Error::Other`] if the value
/// cannot be represented as YAML.
pub fn write<T: Serialize>(path: &Path, value: &T) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
}
let text = serde_yaml_ng::to_string(value).map_err(Error::other)?;
fs::write(path, text).map_err(|e| Error::io(path, e))
}
/// Deserializes a JSON file into any type.
///
/// Used only for importing legacy banks and for reading emitted schemas back in
/// tests; the tool's own files are YAML.
///
/// # Arguments
///
/// * `path` - the file to read.
///
/// # Returns
///
/// The deserialized value.
///
/// # Errors
///
/// Returns [`Error::Io`] or [`Error::Json`].
pub fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T> {
let text = fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
serde_json::from_str(&text).map_err(|source| Error::Json {
path: path.to_path_buf(),
source,
})
}
/// Writes a value as pretty-printed JSON.
///
/// # Arguments
///
/// * `path` - the destination file.
/// * `value` - the value to write.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure.
pub fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
}
let text = serde_json::to_string_pretty(value).map_err(Error::other)?;
fs::write(path, format!("{text}\n")).map_err(|e| Error::io(path, e))
}
/// Writes text to a file, creating parent directories as needed.
///
/// # Arguments
///
/// * `path` - the destination file.
/// * `text` - the contents.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure.
pub fn write_text(path: &Path, text: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
}
fs::write(path, text).map_err(|e| Error::io(path, e))
}
/// Lists the `*.yaml` and `*.yml` files in a directory, sorted by name.
///
/// Sorting makes every downstream output deterministic, which matters when the
/// outputs are committed.
///
/// # Arguments
///
/// * `dir` - the directory to scan.
///
/// # Returns
///
/// The paths, empty when the directory does not exist.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory exists but cannot be read.
pub fn list_yaml(dir: &Path) -> Result<Vec<std::path::PathBuf>> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in fs::read_dir(dir).map_err(|e| Error::io(dir, e))? {
let entry = entry.map_err(|e| Error::io(dir, e))?;
let path = entry.path();
let is_yaml = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("yaml") || e.eq_ignore_ascii_case("yml"))
.unwrap_or(false);
if is_yaml && path.is_file() {
out.push(path);
}
}
out.sort();
Ok(out)
}
/// Deserializes a scalar as a string, whether it was written quoted or not.
///
/// # Arguments
///
/// * `d` - the deserializer.
///
/// # Returns
///
/// The value as a string.
///
/// # Errors
///
/// Returns a deserialization error for non-scalar input.
pub fn flexible_string<'de, D>(d: D) -> std::result::Result<String, D::Error>
where
D: Deserializer<'de>,
{
struct V;
impl<'a> Visitor<'a> for V {
type Value = String;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a version such as \"1.0\"")
}
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<String, E> {
Ok(v.to_string())
}
fn visit_f64<E: de::Error>(self, v: f64) -> std::result::Result<String, E> {
// 1.0 must render as "1.0", not "1".
Ok(format!("{v:.1}"))
}
fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<String, E> {
Ok(format!("{v}.0"))
}
fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<String, E> {
Ok(format!("{v}.0"))
}
}
d.deserialize_any(V)
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct Versioned {
#[serde(deserialize_with = "flexible_string")]
v: String,
}
#[test]
fn flexible_string_accepts_quoted_and_bare_versions() {
for (src, want) in [
("v: \"1.0\"", "1.0"),
("v: 1.0", "1.0"),
("v: 2", "2.0"),
("v: \"1.10\"", "1.10"),
] {
let got: Versioned = serde_yaml_ng::from_str(src).expect(src);
assert_eq!(got.v, want, "{src}");
}
}
}
+242
View File
@@ -0,0 +1,242 @@
//! A minimal ZIP writer, stored (uncompressed) entries only.
//!
//! Canvas needs a `.zip` to import a QTI package, and that is the only reason
//! this crate needs ZIP at all. Writing ~120 lines of well-understood format
//! beats taking a dependency whose API has changed shape several times, and it
//! buys two things worth having: the archives are byte-for-byte reproducible,
//! because the timestamp is fixed rather than read from the clock, and a QTI
//! package diffs cleanly in a course repository.
//!
//! Entries are stored rather than deflated. A quiz package is a few tens of
//! kilobytes of XML, so compression saves nothing that matters, and the
//! Gradescope and Canvas exports in the wild are stored too.
use std::io::Write;
use std::path::Path;
use crate::error::{Error, Result};
/// CRC-32 (IEEE 802.3) of a byte slice.
///
/// # Arguments
///
/// * `data` - the bytes to checksum.
///
/// # Returns
///
/// The checksum.
fn crc32(data: &[u8]) -> u32 {
let mut table = [0u32; 256];
for (i, entry) in table.iter_mut().enumerate() {
let mut c = i as u32;
for _ in 0..8 {
c = if c & 1 != 0 {
0xEDB8_8320 ^ (c >> 1)
} else {
c >> 1
};
}
*entry = c;
}
let mut crc = 0xFFFF_FFFFu32;
for b in data {
crc = table[((crc ^ *b as u32) & 0xFF) as usize] ^ (crc >> 8);
}
crc ^ 0xFFFF_FFFF
}
/// One file to place in the archive.
struct Entry {
/// The path inside the archive, always with forward slashes.
name: String,
/// The file contents.
data: Vec<u8>,
/// CRC-32 of `data`.
crc: u32,
/// Byte offset of this entry's local header.
offset: u32,
}
/// Builds a ZIP archive in memory.
#[derive(Default)]
pub struct ZipBuilder {
entries: Vec<Entry>,
body: Vec<u8>,
}
/// The fixed DOS timestamp used for every entry: 1980-01-01 00:00:00.
///
/// A real clock value would make otherwise identical packages differ, which
/// defeats the point of committing them.
const DOS_TIME: u16 = 0;
/// The DOS date for 1980-01-01.
const DOS_DATE: u16 = 0x0021;
impl ZipBuilder {
/// Creates an empty archive.
pub fn new() -> ZipBuilder {
ZipBuilder::default()
}
/// Adds a file to the archive.
///
/// # Arguments
///
/// * `name` - the path inside the archive.
/// * `data` - the contents.
pub fn add(&mut self, name: &str, data: impl Into<Vec<u8>>) {
let data = data.into();
let crc = crc32(&data);
let offset = self.body.len() as u32;
let name = name.replace('\\', "/");
let name_bytes = name.as_bytes();
// Local file header.
self.body.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
self.body.extend_from_slice(&20u16.to_le_bytes()); // version needed
self.body.extend_from_slice(&0u16.to_le_bytes()); // flags
self.body.extend_from_slice(&0u16.to_le_bytes()); // method: stored
self.body.extend_from_slice(&DOS_TIME.to_le_bytes());
self.body.extend_from_slice(&DOS_DATE.to_le_bytes());
self.body.extend_from_slice(&crc.to_le_bytes());
self.body
.extend_from_slice(&(data.len() as u32).to_le_bytes());
self.body
.extend_from_slice(&(data.len() as u32).to_le_bytes());
self.body
.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
self.body.extend_from_slice(&0u16.to_le_bytes()); // extra field length
self.body.extend_from_slice(name_bytes);
self.body.extend_from_slice(&data);
self.entries.push(Entry {
name,
data,
crc,
offset,
});
}
/// Adds a text file to the archive.
///
/// # Arguments
///
/// * `name` - the path inside the archive.
/// * `text` - the contents.
pub fn add_text(&mut self, name: &str, text: &str) {
self.add(name, text.as_bytes().to_vec());
}
/// Serializes the archive.
///
/// # Returns
///
/// The complete ZIP file bytes.
pub fn finish(self) -> Vec<u8> {
let mut out = self.body;
let cd_offset = out.len() as u32;
for e in &self.entries {
let name = e.name.as_bytes();
out.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
out.extend_from_slice(&20u16.to_le_bytes()); // version made by
out.extend_from_slice(&20u16.to_le_bytes()); // version needed
out.extend_from_slice(&0u16.to_le_bytes()); // flags
out.extend_from_slice(&0u16.to_le_bytes()); // method: stored
out.extend_from_slice(&DOS_TIME.to_le_bytes());
out.extend_from_slice(&DOS_DATE.to_le_bytes());
out.extend_from_slice(&e.crc.to_le_bytes());
out.extend_from_slice(&(e.data.len() as u32).to_le_bytes());
out.extend_from_slice(&(e.data.len() as u32).to_le_bytes());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // extra
out.extend_from_slice(&0u16.to_le_bytes()); // comment
out.extend_from_slice(&0u16.to_le_bytes()); // disk number
out.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
out.extend_from_slice(&0u32.to_le_bytes()); // external attrs
out.extend_from_slice(&e.offset.to_le_bytes());
out.extend_from_slice(name);
}
let cd_size = out.len() as u32 - cd_offset;
// End of central directory.
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // this disk
out.extend_from_slice(&0u16.to_le_bytes()); // disk with cd
out.extend_from_slice(&(self.entries.len() as u16).to_le_bytes());
out.extend_from_slice(&(self.entries.len() as u16).to_le_bytes());
out.extend_from_slice(&cd_size.to_le_bytes());
out.extend_from_slice(&cd_offset.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // comment length
out
}
/// Writes the archive to a file.
///
/// # Arguments
///
/// * `path` - the destination.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure.
pub fn write_to(self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
}
let bytes = self.finish();
let mut f = std::fs::File::create(path).map_err(|e| Error::io(path, e))?;
f.write_all(&bytes).map_err(|e| Error::io(path, e))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crc32_matches_the_known_vector() {
// The canonical check value for CRC-32/ISO-HDLC over "123456789".
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
assert_eq!(crc32(b""), 0);
}
#[test]
fn produces_a_recognizable_archive() {
let mut z = ZipBuilder::new();
z.add_text("imsmanifest.xml", "<manifest/>");
z.add_text("quiz.xml", "<questestinterop/>");
let bytes = z.finish();
assert_eq!(&bytes[0..4], b"PK\x03\x04", "starts with a local header");
// End-of-central-directory signature appears near the end.
let eocd = bytes
.windows(4)
.rposition(|w| w == b"PK\x05\x06")
.expect("has an end-of-central-directory record");
assert_eq!(
u16::from_le_bytes([bytes[eocd + 10], bytes[eocd + 11]]),
2,
"records two entries"
);
assert!(bytes.windows(15).any(|w| w == b"imsmanifest.xml"));
}
#[test]
fn output_is_byte_for_byte_reproducible() {
let build = || {
let mut z = ZipBuilder::new();
z.add_text("a.xml", "<a/>");
z.finish()
};
assert_eq!(build(), build());
}
#[test]
fn empty_archive_is_valid() {
let bytes = ZipBuilder::new().finish();
assert_eq!(&bytes[0..4], b"PK\x05\x06");
assert_eq!(bytes.len(), 22);
}
}