feat: initial package draft
This commit is contained in:
@@ -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;
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("→", "->")
|
||||
.replace("↔", "<->")
|
||||
.replace("−", "-");
|
||||
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 `→` is written as `&rarr;`. Skipping that step
|
||||
//! produces XML that parses and renders as literal `→` 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
/// Escapes an attribute value.
|
||||
fn escape_attr(s: &str) -> String {
|
||||
escape_text(s).replace('"', """)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 → b</p>");
|
||||
let xml = n.render(0);
|
||||
// The HTML is character data, so its markup is escaped.
|
||||
assert!(xml.contains("<p>a &rarr; b</p>"), "{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(""quoted""));
|
||||
assert!(xml.contains("&"));
|
||||
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");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(" "), "");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 `→` is not itself escaped into `&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", "≥", "\u{2265}"),
|
||||
("#sym.lt.eq", "≤", "\u{2264}"),
|
||||
("#sym.eq.not", "≠", "\u{2260}"),
|
||||
("#sym.plus.minus", "±", "\u{00b1}"),
|
||||
("#sym.arrow.r", "→", "\u{2192}"),
|
||||
("#sym.arrow.l", "←", "\u{2190}"),
|
||||
("#sym.arrow.lr", "↔", "\u{2194}"),
|
||||
("#sym.rightarrow", "→", "\u{2192}"),
|
||||
("#sym.leftarrow", "←", "\u{2190}"),
|
||||
("#sym.times", "×", "\u{00d7}"),
|
||||
("#sym.dot", "·", "\u{00b7}"),
|
||||
("#sym.degree", "°", "\u{00b0}"),
|
||||
("#sym.infinity", "∞", "\u{221e}"),
|
||||
("#sym.approx", "≈", "\u{2248}"),
|
||||
("#sym.alpha", "α", "\u{03b1}"),
|
||||
("#sym.beta", "β", "\u{03b2}"),
|
||||
("#sym.gamma", "γ", "\u{03b3}"),
|
||||
("#sym.delta.cap", "Δ", "\u{0394}"),
|
||||
("#sym.delta", "δ", "\u{03b4}"),
|
||||
("#sym.epsilon", "ε", "\u{03b5}"),
|
||||
("#sym.lambda", "λ", "\u{03bb}"),
|
||||
("#sym.mu", "μ", "\u{03bc}"),
|
||||
("#sym.pi", "π", "\u{03c0}"),
|
||||
("#sym.sigma", "σ", "\u{03c3}"),
|
||||
("#sym.tau", "τ", "\u{03c4}"),
|
||||
("#sym.phi", "φ", "\u{03c6}"),
|
||||
("#sym.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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => 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 → b</p>");
|
||||
// A literal ampersand is escaped.
|
||||
assert_eq!(to_html("Tris & HCl"), "<p>Tris & HCl</p>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_to_pass_through_html() {
|
||||
let out = to_html("<script>alert(1)</script>");
|
||||
assert!(!out.contains("<script>"));
|
||||
assert!(out.contains("<script>"));
|
||||
}
|
||||
|
||||
#[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
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user