Compare commits
9
Commits
9755417899
...
prelim-use
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6d6ee10b6
|
||
|
|
5ac1e317c0
|
||
|
|
eabc98ad31
|
||
|
|
220363d4d3
|
||
|
|
cea9769022
|
||
|
|
d3f21e913b
|
||
|
|
cfe8a3216c
|
||
|
|
003340dc6f
|
||
|
|
8ec48fb185
|
+384
-42
@@ -28,18 +28,19 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::SCHEMA_VERSION;
|
||||
use crate::assessment::AssessmentFile;
|
||||
use crate::bank::BankFile;
|
||||
use crate::calibration::{CalibrationFile, Measurement, MeasurementFile, MeasurementMeta};
|
||||
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::item::{Calibration, IrtParams, Item, OptionStat, VariantCalibration};
|
||||
use crate::layout::Layout;
|
||||
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)]
|
||||
@@ -267,6 +268,35 @@ pub fn plan(catalog: &Catalog, store: &Store, opts: &Options) -> Result<Plan> {
|
||||
));
|
||||
}
|
||||
|
||||
// Splitting by option set means an item administered three times with three
|
||||
// different sets has three cells of 24 rather than one of 72. That is the
|
||||
// honest picture, and it is worth saying out loud rather than leaving
|
||||
// someone to read an IRT fit that was never possible.
|
||||
for (uid, appearances) in &by_item {
|
||||
let mut sizes: Vec<usize> = Vec::new();
|
||||
for (_, analysis) in appearances {
|
||||
if analysis.variant.is_some() {
|
||||
sizes.push(analysis.n);
|
||||
}
|
||||
}
|
||||
if sizes.len() > 1 {
|
||||
let distinct: BTreeSet<&str> = appearances
|
||||
.iter()
|
||||
.filter_map(|(_, a)| a.variant.as_deref())
|
||||
.collect();
|
||||
if distinct.len() > 1 {
|
||||
warnings.push(format!(
|
||||
"{uid}: {} option sets across {} administrations, largest n = {}. \
|
||||
Statistics are kept per set, because a stem shown with different \
|
||||
distractors is a different item.",
|
||||
distinct.len(),
|
||||
appearances.len(),
|
||||
sizes.iter().copied().max().unwrap_or(0)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut changes = Vec::new();
|
||||
|
||||
for (uid, appearances) in &by_item {
|
||||
@@ -304,6 +334,12 @@ pub fn plan(catalog: &Catalog, store: &Store, opts: &Options) -> Result<Plan> {
|
||||
option_stats: pooled.option_stats.clone(),
|
||||
irt: irt_params,
|
||||
flags: pooled.flags.clone(),
|
||||
variants: variant_records(&entry.item, appearances, previous_variants(entry)),
|
||||
options: BTreeMap::new(),
|
||||
};
|
||||
let calibration = Calibration {
|
||||
options: option_histories(&calibration.variants),
|
||||
..calibration
|
||||
};
|
||||
|
||||
let previous = entry.item.calibration.as_ref();
|
||||
@@ -580,60 +616,123 @@ fn diff_calibration(previous: Option<&Calibration>, next: &Calibration) -> Vec<S
|
||||
out
|
||||
}
|
||||
|
||||
/// Applies a plan, rewriting the affected bank files.
|
||||
/// Applies a plan, rewriting the calibration store.
|
||||
///
|
||||
/// 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.
|
||||
/// One file, `analysis/calibration.yaml`, and never a bank. A bank is reviewed
|
||||
/// for what it asks; its history should be a record of wording decisions, not
|
||||
/// of every grading run. The store is re-read immediately before editing, so a
|
||||
/// plan built against a store that has since changed on disk fails loudly
|
||||
/// rather than clobbering the newer version.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layout` - the course layout, for where the store lives.
|
||||
/// * `plan` - the plan to apply.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The bank files rewritten.
|
||||
/// The file written.
|
||||
///
|
||||
/// # 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();
|
||||
/// Returns [`Error::Io`] on a write failure and [`Error::Yaml`] if the existing
|
||||
/// store does not parse.
|
||||
pub fn apply(layout: &Layout, plan: &Plan) -> Result<PathBuf> {
|
||||
let path = layout.calibration_file();
|
||||
let mut store = CalibrationFile::load(&path)?;
|
||||
|
||||
for change in &plan.changes {
|
||||
by_file.entry(&change.path).or_default().push(change);
|
||||
store
|
||||
.items
|
||||
.insert(change.uid.clone(), change.calibration.clone());
|
||||
}
|
||||
|
||||
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());
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
|
||||
}
|
||||
Ok(written)
|
||||
store.save(&path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Records what one administration measured, as a file that is never rewritten.
|
||||
///
|
||||
/// The audit trail under the pooled store: these are the numbers one exam
|
||||
/// produced, on a day, under a named model. Keeping them means the history
|
||||
/// survives losing `data/`, which is ignored by git precisely because every row
|
||||
/// of it carries a student.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layout` - the course layout.
|
||||
/// * `administration` - the administration id, which names the file.
|
||||
/// * `analysis` - the classical analysis of that administration.
|
||||
/// * `catalog` - the loaded course, for the digests each item was measured
|
||||
/// against.
|
||||
/// * `record` - the assessment record, for the term and date.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The two files written: one row per question, one row per question and
|
||||
/// option.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Usage`] when a record for this administration already
|
||||
/// exists, since an administration happened once.
|
||||
pub fn record_measurements(
|
||||
layout: &Layout,
|
||||
administration: &str,
|
||||
analysis: &Analysis,
|
||||
catalog: &Catalog,
|
||||
record: Option<&AssessmentFile>,
|
||||
) -> Result<Vec<PathBuf>> {
|
||||
let mut items = Vec::new();
|
||||
for item in &analysis.items {
|
||||
let Some(uid) = &item.item_ref else { continue };
|
||||
let entry = catalog.get(uid);
|
||||
items.push(Measurement {
|
||||
item: uid.clone(),
|
||||
number: item.number,
|
||||
variant: item.variant.clone(),
|
||||
stem_digest: entry.map(|e| e.item.stem_digest()),
|
||||
n: item.n,
|
||||
p_value: Some(round4(item.p_value)),
|
||||
point_biserial: item.point_biserial.map(round4),
|
||||
discrimination_index: item.discrimination_index.map(round4),
|
||||
key: item.key.clone(),
|
||||
option_stats: item
|
||||
.options
|
||||
.iter()
|
||||
.map(|(id, o)| (id.clone(), o.to_option_stat()))
|
||||
.collect(),
|
||||
irt: None,
|
||||
flags: item.flags.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let file = MeasurementFile {
|
||||
schema_version: SCHEMA_VERSION.to_string(),
|
||||
administration: MeasurementMeta {
|
||||
id: administration.to_string(),
|
||||
assessment: record
|
||||
.map(|r| r.assessment.id.clone())
|
||||
.unwrap_or_else(|| administration.to_string()),
|
||||
term: record.and_then(|r| r.assessment.term.clone()),
|
||||
date: record.and_then(|r| r.assessment.date),
|
||||
forms: record
|
||||
.map(|r| r.forms.iter().map(|f| f.id.clone()).collect())
|
||||
.unwrap_or_default(),
|
||||
// The cohort, taken as the largest per-item n: a student who
|
||||
// skipped question 7 still sat the exam.
|
||||
n_examinees: analysis.items.iter().map(|i| i.n).max().unwrap_or(0),
|
||||
model: None,
|
||||
generated: Some(Date::today()),
|
||||
coursebank: Some(crate::VERSION.to_string()),
|
||||
},
|
||||
items,
|
||||
};
|
||||
|
||||
file.write_csv(&layout.measurements())
|
||||
}
|
||||
|
||||
/// Builds a plan for a single administration, from an in-memory analysis.
|
||||
@@ -703,6 +802,16 @@ pub fn plan_from_analysis(
|
||||
.collect(),
|
||||
irt: irt_params,
|
||||
flags: item.flags.clone(),
|
||||
variants: variant_records(
|
||||
&entry.item,
|
||||
&[(admin.clone(), item.clone())],
|
||||
previous_variants(entry),
|
||||
),
|
||||
options: BTreeMap::new(),
|
||||
};
|
||||
let calibration = Calibration {
|
||||
options: option_histories(&calibration.variants),
|
||||
..calibration
|
||||
};
|
||||
|
||||
let previous = entry.item.calibration.as_ref();
|
||||
@@ -730,6 +839,171 @@ pub fn plan_from_analysis(
|
||||
}
|
||||
}
|
||||
|
||||
/// The variant records an item already has, to be merged with the new ones.
|
||||
fn previous_variants(entry: &crate::catalog::Entry) -> Vec<VariantCalibration> {
|
||||
entry
|
||||
.item
|
||||
.calibration
|
||||
.as_ref()
|
||||
.map(|c| c.variants.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Builds one calibration record per option set the item was administered in.
|
||||
///
|
||||
/// The records this pass computes replace the stored ones for the same variant
|
||||
/// and leave the rest alone. That is what makes a partial recalibration safe:
|
||||
/// a pass given only this term's data must not silently discard the numbers for
|
||||
/// an option set that was retired two terms ago.
|
||||
///
|
||||
/// Statistics come from the same [`pool`] used for the flat summary, so the two
|
||||
/// agree for an item whose pool is its form — the case every pre-2.0 item is in.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `item` - the bank item.
|
||||
/// * `appearances` - the administration id and analysis of each appearance.
|
||||
/// * `previous` - the records already stored.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The merged records, in variant order.
|
||||
fn variant_records(
|
||||
item: &Item,
|
||||
appearances: &[(String, ItemAnalysis)],
|
||||
previous: Vec<VariantCalibration>,
|
||||
) -> Vec<VariantCalibration> {
|
||||
// Appearances whose administration mixed two option sets under one question
|
||||
// number carry no variant, and there is no set for them to describe.
|
||||
let mut by_variant: BTreeMap<String, Vec<(String, ItemAnalysis)>> = BTreeMap::new();
|
||||
for (admin, analysis) in appearances {
|
||||
if let Some(variant) = &analysis.variant {
|
||||
by_variant
|
||||
.entry(variant.clone())
|
||||
.or_default()
|
||||
.push((admin.clone(), analysis.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut merged: BTreeMap<String, VariantCalibration> = previous
|
||||
.into_iter()
|
||||
.map(|v| (v.variant.clone(), v))
|
||||
.collect();
|
||||
|
||||
for (variant, group) in by_variant {
|
||||
let pooled = pool(&group);
|
||||
// The option set is recovered from the digest's own record when the
|
||||
// stored one has it, and from the options that were actually chosen
|
||||
// otherwise, so a record written from data alone still says what it
|
||||
// describes.
|
||||
let (key, distractors) = describe(item, &variant, &group, merged.get(&variant));
|
||||
merged.insert(
|
||||
variant.clone(),
|
||||
VariantCalibration {
|
||||
variant,
|
||||
key,
|
||||
distractors,
|
||||
administrations: group.iter().map(|(a, _)| a.clone()).collect(),
|
||||
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),
|
||||
option_stats: pooled.option_stats.clone(),
|
||||
irt: None,
|
||||
flags: pooled.flags.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
merged.into_values().collect()
|
||||
}
|
||||
|
||||
/// Which options a variant administered.
|
||||
///
|
||||
/// Prefers what a stored record already says. Failing that, the options that
|
||||
/// appear in the statistics are the ones students saw, and the item says which
|
||||
/// of those are keyed.
|
||||
fn describe(
|
||||
item: &Item,
|
||||
variant: &str,
|
||||
group: &[(String, ItemAnalysis)],
|
||||
stored: Option<&VariantCalibration>,
|
||||
) -> (Vec<String>, Vec<String>) {
|
||||
if let Some(stored) = stored {
|
||||
if !stored.key.is_empty()
|
||||
&& item.variant_digest(&stored.key, &stored.distractors) == variant
|
||||
{
|
||||
return (stored.key.clone(), stored.distractors.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||
for (_, analysis) in group {
|
||||
seen.extend(analysis.options.keys().cloned());
|
||||
}
|
||||
let keyed: BTreeSet<String> = group
|
||||
.iter()
|
||||
.flat_map(|(_, a)| a.key.iter().cloned())
|
||||
.collect();
|
||||
|
||||
let key: Vec<String> = seen
|
||||
.iter()
|
||||
.filter(|o| keyed.contains(*o))
|
||||
.cloned()
|
||||
.collect();
|
||||
let distractors: Vec<String> = seen
|
||||
.iter()
|
||||
.filter(|o| !keyed.contains(*o))
|
||||
.cloned()
|
||||
.collect();
|
||||
(key, distractors)
|
||||
}
|
||||
|
||||
/// Summarizes what each option has done across every set it appeared in.
|
||||
///
|
||||
/// Deliberately coarse, because selection rates are shares of a fixed set and
|
||||
/// averaging them across different sets is not a statistic. The one claim this
|
||||
/// view supports is the one worth having: an option that draws nobody in any
|
||||
/// set it has appeared in is not doing anything, and that is the evidence for
|
||||
/// retiring it — evidence a single administration cannot provide.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `variants` - the per-variant records.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// One history per option id.
|
||||
fn option_histories(
|
||||
variants: &[VariantCalibration],
|
||||
) -> BTreeMap<String, crate::item::OptionHistory> {
|
||||
let mut out: BTreeMap<String, crate::item::OptionHistory> = BTreeMap::new();
|
||||
let mut rates: BTreeMap<String, Vec<f64>> = BTreeMap::new();
|
||||
|
||||
for variant in variants {
|
||||
let n = variant.n_examinees.unwrap_or(0);
|
||||
for (option, stat) in &variant.option_stats {
|
||||
let entry = out.entry(option.clone()).or_default();
|
||||
entry.appearances += 1;
|
||||
entry.n_examinees += n;
|
||||
if let Some(rate) = stat.selection_rate {
|
||||
rates.entry(option.clone()).or_default().push(rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (option, entry) in &mut out {
|
||||
if let Some(seen) = rates.get(option) {
|
||||
if !seen.is_empty() {
|
||||
entry.mean_selection_rate =
|
||||
Some(round4(seen.iter().sum::<f64>() / seen.len() as f64));
|
||||
entry.never_chosen = seen.iter().all(|r| *r <= f64::EPSILON);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rounds to four decimals.
|
||||
fn round4(x: f64) -> f64 {
|
||||
(x * 1e4).round() / 1e4
|
||||
@@ -754,9 +1028,77 @@ mod tests {
|
||||
option_stats: BTreeMap::new(),
|
||||
irt: None,
|
||||
flags: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
options: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn stat(rate: f64) -> OptionStat {
|
||||
OptionStat {
|
||||
selection_rate: Some(rate),
|
||||
point_biserial: None,
|
||||
upper_group_rate: None,
|
||||
lower_group_rate: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn variant(id: &str, n: usize, dead_rate: f64) -> VariantCalibration {
|
||||
VariantCalibration {
|
||||
variant: id.into(),
|
||||
n_examinees: Some(n),
|
||||
option_stats: [
|
||||
("o-key".to_string(), stat(0.8)),
|
||||
("o-dead".to_string(), stat(dead_rate)),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
..VariantCalibration::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_option_that_draws_nobody_is_only_visible_across_sets() {
|
||||
let histories = option_histories(&[variant("v1", 50, 0.0), variant("v2", 46, 0.0)]);
|
||||
|
||||
let dead = &histories["o-dead"];
|
||||
assert_eq!(dead.appearances, 2);
|
||||
assert_eq!(dead.n_examinees, 96);
|
||||
// The claim the cross-variant view exists to support, and the one a
|
||||
// single administration cannot make.
|
||||
assert!(dead.never_chosen);
|
||||
|
||||
assert!(!histories["o-key"].never_chosen);
|
||||
assert_eq!(histories["o-key"].mean_selection_rate, Some(0.8));
|
||||
|
||||
// One set where it drew is enough to stop the claim.
|
||||
let mixed = option_histories(&[variant("v1", 50, 0.0), variant("v2", 46, 0.04)]);
|
||||
assert!(!mixed["o-dead"].never_chosen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pass_with_no_data_for_an_item_keeps_the_records_it_has() {
|
||||
let item: Item = serde_yaml_ng::from_str(
|
||||
r#"id: q-x
|
||||
status: approved
|
||||
level: 1
|
||||
stem: s
|
||||
options:
|
||||
- { id: o-key, text: right, correct: true }
|
||||
- { id: o-one, text: wrong }
|
||||
"#,
|
||||
)
|
||||
.expect("item parses");
|
||||
|
||||
let kept = variant_records(&item, &[], vec![variant("v-old", 96, 0.0)]);
|
||||
assert_eq!(
|
||||
kept.len(),
|
||||
1,
|
||||
"a pass given nothing must not delete history"
|
||||
);
|
||||
assert_eq!(kept[0].variant, "v-old");
|
||||
assert_eq!(kept[0].n_examinees, Some(96));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_calibration_is_all_new() {
|
||||
let next = calibration(0.7, Some(0.3), 24, "abc");
|
||||
|
||||
@@ -134,6 +134,14 @@ pub struct ItemAnalysis {
|
||||
pub number: u32,
|
||||
/// The item's global id, when known.
|
||||
pub item_ref: Option<String>,
|
||||
/// The option set administered, when the rows agree on one.
|
||||
///
|
||||
/// Within one administration an item has one variant, because a placement's
|
||||
/// distractors are drawn once and shared by every form — only the printed
|
||||
/// order differs. `None` means the rows disagreed, which happens when a
|
||||
/// course opts into drawing distractors per form; the statistics below then
|
||||
/// describe a mixture and cannot be pooled by option set.
|
||||
pub variant: Option<String>,
|
||||
/// How many students the item was administered to.
|
||||
pub n: usize,
|
||||
/// How many gave a non-blank response.
|
||||
@@ -553,6 +561,7 @@ pub fn analyze(
|
||||
item_ref: record
|
||||
.and_then(|r| r.placement(*number))
|
||||
.map(|p| p.item.clone()),
|
||||
variant: one_variant(set, *number),
|
||||
n,
|
||||
n_answered,
|
||||
blank_rate: blank as f64 / responded as f64,
|
||||
@@ -906,6 +915,25 @@ fn reliability(coded: &[Vec<f64>], totals: &[f64], p_values: &[f64], rpbs: &[f64
|
||||
/// # Returns
|
||||
///
|
||||
/// The letters that appear on full-credit responses.
|
||||
/// The single variant every row for one question names, if they agree.
|
||||
///
|
||||
/// Disagreement is not an error, it is a fact about the administration: a course
|
||||
/// that draws distractors per form has two option sets under one question
|
||||
/// number, and no pooled statistic describes both. Returning `None` is what
|
||||
/// keeps the per-variant records from claiming otherwise.
|
||||
fn one_variant(set: &ResponseSet, number: u32) -> Option<String> {
|
||||
let mut seen: Option<&str> = None;
|
||||
for row in set.rows.iter().filter(|r| r.item_number == number) {
|
||||
let variant = row.variant.as_deref()?;
|
||||
match seen {
|
||||
None => seen = Some(variant),
|
||||
Some(first) if first == variant => {}
|
||||
Some(_) => return None,
|
||||
}
|
||||
}
|
||||
seen.map(str::to_string)
|
||||
}
|
||||
|
||||
fn infer_key(rows: &[&crate::responses::Response]) -> Vec<String> {
|
||||
let mut out: BTreeSet<String> = BTreeSet::new();
|
||||
for r in rows {
|
||||
@@ -1001,6 +1029,7 @@ mod tests {
|
||||
item_number: number,
|
||||
item_ref: None,
|
||||
item_version: None,
|
||||
variant: None,
|
||||
selected: if letter.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
@@ -1015,7 +1044,7 @@ mod tests {
|
||||
score: credit,
|
||||
response_time_seconds: None,
|
||||
level: None,
|
||||
learning_objectives: vec![],
|
||||
learning_targets: vec![],
|
||||
topics: vec![],
|
||||
bonus: false,
|
||||
dropped: false,
|
||||
|
||||
+348
-85
@@ -71,12 +71,12 @@ use serde::Serialize;
|
||||
use crate::assessment::AssessmentFile;
|
||||
use crate::catalog::Catalog;
|
||||
use crate::classical::Analysis;
|
||||
use crate::course::{CourseFile, ReadingRole, Reference};
|
||||
use crate::course::{CourseFile, ReadingRole};
|
||||
use crate::irt::Fit;
|
||||
use crate::item::Citation;
|
||||
use crate::responses::{Response, ResponseSet};
|
||||
use crate::students::{Cohort, Mastery, StudentSummary};
|
||||
use crate::taxonomy::Level;
|
||||
use crate::taxonomy::{Level, Tier};
|
||||
|
||||
/// What to assemble.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -164,6 +164,15 @@ pub struct StudentDiagnostic {
|
||||
/// Per-level performance.
|
||||
pub levels: Vec<LevelRow>,
|
||||
/// Per-objective standing, in the course's own order.
|
||||
///
|
||||
/// Objectives only, never their targets. This is the table that makes a
|
||||
/// claim, and a claim needs a denominator: an objective's row aggregates
|
||||
/// every item tagged to any of its targets, while a target's row usually
|
||||
/// rests on one question and could only ever read "not enough questions to
|
||||
/// say". Mixing the two produced a three-page table where most rows carried
|
||||
/// that mark and the few real classifications were lost among them. The
|
||||
/// specifics live in the two sections built for them: which lectures to go
|
||||
/// back to, and the notes on missed questions.
|
||||
pub objectives: Vec<ObjectiveRow>,
|
||||
/// Objectives they are clearly meeting, worst first among the confident ones.
|
||||
pub strengths: Vec<ObjectiveRef>,
|
||||
@@ -299,6 +308,20 @@ pub struct ObjectiveRef {
|
||||
pub n_items: usize,
|
||||
}
|
||||
|
||||
/// What one question measured, named at both tiers.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct Measured {
|
||||
/// The objective this question's result rolls up to.
|
||||
///
|
||||
/// `None` when the tagged id is an objective with no targets of its own, so
|
||||
/// that a report does not print the same sentence twice.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub objective: Option<String>,
|
||||
/// The target the question was written against.
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
/// One question, described without being reproduced.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
@@ -311,15 +334,19 @@ pub struct QuestionRow {
|
||||
/// The level code.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub level: Option<u8>,
|
||||
/// The objectives it measured.
|
||||
pub objectives: Vec<String>,
|
||||
/// What those objectives ask, in the words the course uses with students.
|
||||
/// The learning targets it measured, by id.
|
||||
pub targets: Vec<String>,
|
||||
/// What this question measured, at both tiers.
|
||||
///
|
||||
/// The objective, not the question. It is printed in full in the objectives
|
||||
/// table already; repeating it next to a missed question saves a student
|
||||
/// working out which of thirty-six rows this one belonged to.
|
||||
/// Both, because each answers a different question a student has in front of
|
||||
/// a missed item. The target says what this question actually asked of them,
|
||||
/// which is the specific thing to go and practise. The objective says which
|
||||
/// row of the table above the mark landed in, which is how they tell whether
|
||||
/// one slip cost them a claim or whether it was one of several. Printing the
|
||||
/// target alone left them unable to connect the note to the table; printing
|
||||
/// the objective alone described something broader than the question.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub objective_texts: Vec<String>,
|
||||
pub measured: Vec<Measured>,
|
||||
/// Whether it was answered correctly.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub correct: Option<bool>,
|
||||
@@ -403,8 +430,14 @@ pub struct LectureFocus {
|
||||
/// Where the slides live, when the course records that.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
/// How many distinct objectives from this lecture were missed.
|
||||
pub n_objectives: usize,
|
||||
/// How many distinct learning targets from this lecture were missed. The
|
||||
/// ranking key.
|
||||
///
|
||||
/// Counting targets rather than objectives keeps the ranking informative: a
|
||||
/// lecture where four separate performances went wrong needs more time than
|
||||
/// one where a single performance was missed twice, and counting objectives
|
||||
/// would score those the same.
|
||||
pub n_targets: usize,
|
||||
/// How many questions from this lecture were missed.
|
||||
pub n_questions: usize,
|
||||
/// Which questions, so a student can line this up with their paper.
|
||||
@@ -413,9 +446,14 @@ pub struct LectureFocus {
|
||||
/// The slides those questions came from, when the items record them.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub slides: Vec<u32>,
|
||||
/// The objectives that went wrong here, in the course's own words.
|
||||
/// The learning targets that went wrong here, in the course's own words.
|
||||
///
|
||||
/// Targets rather than objectives, because this section answers "what do I
|
||||
/// go and restudy". "You missed the objective on binding" sends a student to
|
||||
/// a whole lecture; "you missed reading a dissociation constant off an
|
||||
/// isotherm" sends them to one page of it.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub objectives: Vec<String>,
|
||||
pub targets: Vec<String>,
|
||||
}
|
||||
|
||||
/// What to read about one objective.
|
||||
@@ -508,13 +546,12 @@ pub fn student(
|
||||
let objectives: Vec<ObjectiveRow> = summary
|
||||
.objectives
|
||||
.iter()
|
||||
// Objectives only; see `StudentDiagnostic::objectives`.
|
||||
.filter(|mastery| mastery.tier == Tier::Objective)
|
||||
.map(|mastery| ObjectiveRow {
|
||||
id: mastery.objective.clone(),
|
||||
id: mastery.id.clone(),
|
||||
text: mastery.text.clone(),
|
||||
unit: course
|
||||
.learning_objectives
|
||||
.get(&mastery.objective)
|
||||
.and_then(|o| o.unit.clone()),
|
||||
unit: course.objective_unit(&mastery.id).map(str::to_string),
|
||||
n_items: mastery.n_items,
|
||||
credit: mastery.credit,
|
||||
rate: mastery.rate,
|
||||
@@ -700,15 +737,26 @@ fn question_row(
|
||||
number: row.item_number,
|
||||
position: row.form_position.filter(|p| *p != row.item_number),
|
||||
level: row.level.map(|l| l.code()),
|
||||
objectives: row.learning_objectives.clone(),
|
||||
objective_texts: if missed {
|
||||
row.learning_objectives
|
||||
targets: row.learning_targets.clone(),
|
||||
measured: if missed {
|
||||
let course = &catalog.course;
|
||||
row.learning_targets
|
||||
.iter()
|
||||
.map(|id| catalog.course.objective_text(id))
|
||||
.map(|id| {
|
||||
let objective = course.objective_for(id);
|
||||
Measured {
|
||||
// An objective with no targets of its own is tagged
|
||||
// directly, and then the two tiers are the same row.
|
||||
// Saying it twice would read as an error, so the
|
||||
// objective is left out.
|
||||
objective: (objective != id).then(|| course.text_for(objective)),
|
||||
target: course.text_for(id),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
// Only where it earns its space. Every question already carries its
|
||||
// objective ids, and the objectives table prints all of them.
|
||||
// target ids, and a correct answer needs no explaining.
|
||||
Vec::new()
|
||||
},
|
||||
correct: row.correct,
|
||||
@@ -749,9 +797,9 @@ fn item_readings(course: &CourseFile, citations: &[Citation]) -> Vec<ItemReading
|
||||
|
||||
let (label, title, url) = match reference {
|
||||
Some((key, reference)) => (
|
||||
reference.label.as_deref().unwrap_or(key).to_string(),
|
||||
reference.label_or(key).to_string(),
|
||||
Some(reference.title.clone()),
|
||||
resolve_citation_url(citation, reference),
|
||||
citation.href(reference),
|
||||
),
|
||||
None => (citation.display(), None, citation.url.clone()),
|
||||
};
|
||||
@@ -773,20 +821,6 @@ fn item_readings(course: &CourseFile, citations: &[Citation]) -> Vec<ItemReading
|
||||
out
|
||||
}
|
||||
|
||||
/// A citation's own URL, else the reference's `base_url` joined with its `path`.
|
||||
fn resolve_citation_url(citation: &Citation, reference: &Reference) -> Option<String> {
|
||||
if let Some(url) = &citation.url {
|
||||
return Some(url.clone());
|
||||
}
|
||||
let path = citation.path.as_deref()?;
|
||||
let base = reference.base_url.as_deref()?;
|
||||
Some(match (base.ends_with('/'), path.starts_with('/')) {
|
||||
(true, true) => format!("{base}{}", &path[1..]),
|
||||
(false, false) => format!("{base}/{path}"),
|
||||
_ => format!("{base}{path}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Ranks the lectures behind a student's missed questions.
|
||||
///
|
||||
/// A lecture earns its place by how many distinct objectives went wrong in it,
|
||||
@@ -805,7 +839,7 @@ fn lecture_focus(catalog: &Catalog, rows: &[&Response], opts: &Options) -> Vec<L
|
||||
/// What has accumulated for one lecture so far.
|
||||
#[derive(Default)]
|
||||
struct Tally {
|
||||
objectives: BTreeSet<String>,
|
||||
targets: BTreeSet<String>,
|
||||
questions: BTreeSet<u32>,
|
||||
slides: BTreeSet<u32>,
|
||||
}
|
||||
@@ -814,10 +848,9 @@ fn lecture_focus(catalog: &Catalog, rows: &[&Response], opts: &Options) -> Vec<L
|
||||
let mut tallies: BTreeMap<String, Tally> = BTreeMap::new();
|
||||
|
||||
for row in rows.iter().filter(|r| r.counts() && r.credit < 0.999) {
|
||||
// Two routes to a lecture, and both are wanted. The objective registry
|
||||
// knows which lectures develop an objective; the item knows which lecture
|
||||
// it was written from, which is the finer answer when an objective spans
|
||||
// several.
|
||||
// Two routes to a lecture, and both are wanted. The registry knows
|
||||
// which lectures develop a target; the item knows which lecture it was
|
||||
// written from, which is the finer answer when a target spans several.
|
||||
let mut lectures: BTreeSet<String> = BTreeSet::new();
|
||||
let mut slides: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
|
||||
|
||||
@@ -830,15 +863,13 @@ fn lecture_focus(catalog: &Catalog, rows: &[&Response], opts: &Options) -> Vec<L
|
||||
.extend(source.slides.iter().copied());
|
||||
}
|
||||
}
|
||||
for objective in &row.learning_objectives {
|
||||
if let Some(entry) = course.learning_objectives.get(objective) {
|
||||
lectures.extend(entry.lectures.iter().cloned());
|
||||
}
|
||||
for target in &row.learning_targets {
|
||||
lectures.extend(course.lectures_for(target).iter().cloned());
|
||||
}
|
||||
|
||||
for lecture in lectures {
|
||||
let tally = tallies.entry(lecture.clone()).or_default();
|
||||
tally.objectives.extend(row.learning_objectives.clone());
|
||||
tally.targets.extend(row.learning_targets.clone());
|
||||
tally.questions.insert(row.item_number);
|
||||
if let Some(numbers) = slides.get(&lecture) {
|
||||
tally.slides.extend(numbers.iter().copied());
|
||||
@@ -855,7 +886,7 @@ fn lecture_focus(catalog: &Catalog, rows: &[&Response], opts: &Options) -> Vec<L
|
||||
.map(|l| l.title.clone())
|
||||
.unwrap_or_else(|| lecture.clone()),
|
||||
url: record.and_then(|l| l.slides_url.clone()),
|
||||
n_objectives: tally.objectives.len(),
|
||||
n_targets: tally.targets.len(),
|
||||
n_questions: tally.questions.len(),
|
||||
questions: if opts.questions {
|
||||
tally.questions.iter().copied().collect()
|
||||
@@ -863,19 +894,15 @@ fn lecture_focus(catalog: &Catalog, rows: &[&Response], opts: &Options) -> Vec<L
|
||||
Vec::new()
|
||||
},
|
||||
slides: tally.slides.iter().copied().collect(),
|
||||
objectives: tally
|
||||
.objectives
|
||||
.iter()
|
||||
.map(|id| course.objective_text(id))
|
||||
.collect(),
|
||||
targets: tally.targets.iter().map(|id| course.text_for(id)).collect(),
|
||||
lecture,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
out.sort_by(|a, b| {
|
||||
b.n_objectives
|
||||
.cmp(&a.n_objectives)
|
||||
b.n_targets
|
||||
.cmp(&a.n_targets)
|
||||
.then(b.n_questions.cmp(&a.n_questions))
|
||||
.then(a.lecture.cmp(&b.lecture))
|
||||
});
|
||||
@@ -979,6 +1006,13 @@ pub struct CohortDiagnostic {
|
||||
pub predictions: PredictionSummary,
|
||||
/// Questions worth revisiting before reuse, worst first.
|
||||
pub revise: Vec<CohortQuestionRow>,
|
||||
/// The dropped items, described but not measured.
|
||||
///
|
||||
/// Kept out of [`CohortDiagnostic::questions`] so that no statistic above
|
||||
/// silently includes an item that was thrown out, and reported alongside it
|
||||
/// in the evidence section so that dropping a question does not erase the
|
||||
/// evidence for having dropped it.
|
||||
pub dropped_detail: Vec<CohortQuestionRow>,
|
||||
/// One row per form, when more than one was given.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub forms: Vec<FormRow>,
|
||||
@@ -1092,11 +1126,31 @@ pub struct CohortQuestionRow {
|
||||
/// The level code.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub level: Option<u8>,
|
||||
/// The objectives it measured.
|
||||
pub objectives: Vec<String>,
|
||||
/// What those objectives ask, in the course's own words.
|
||||
/// The learning targets it measured, by id.
|
||||
pub targets: Vec<String>,
|
||||
/// What those targets ask, in the course's own words.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub objective_texts: Vec<String>,
|
||||
pub target_texts: Vec<String>,
|
||||
/// Whether the item was dropped from scoring.
|
||||
///
|
||||
/// A dropped item carries no p, r, or D, and appears in none of the
|
||||
/// statistics above. It still appears in the evidence section, because the
|
||||
/// option spread that justified dropping it is the record of why, and that
|
||||
/// record should survive re-running the report afterwards.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub dropped: bool,
|
||||
/// Whether the drop was applied as full credit to everyone.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub dropped_full_credit: bool,
|
||||
/// The question as written.
|
||||
///
|
||||
/// The instructor report reads better with it than without: a row of option
|
||||
/// shares says a distractor drew 44% of the class, and only the stem says
|
||||
/// whether that is a second defensible reading. Absent when the item has
|
||||
/// left the bank, and withheld from any report that is not the instructor
|
||||
/// copy.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stem: Option<String>,
|
||||
/// Where the item was taught, as lecture titles and slide numbers.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub taught_in: Vec<String>,
|
||||
@@ -1144,8 +1198,25 @@ pub struct CohortQuestionRow {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct OptionRow {
|
||||
/// The bank letter.
|
||||
/// The bank letter, which is the one every statistic is keyed by.
|
||||
pub letter: String,
|
||||
/// The option as written, for a report that shows the question.
|
||||
///
|
||||
/// Absent when the item is no longer in the bank, which is why this is an
|
||||
/// option rather than an empty string: a missing option and an empty one
|
||||
/// are different facts.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
/// What this option was lettered on each printed form, worst case one entry
|
||||
/// per form.
|
||||
///
|
||||
/// Shuffling means the bank's option C is a different letter on every form,
|
||||
/// so a statistic reported against C cannot be checked against a student's
|
||||
/// paper without this map. It is the first thing anyone needs when a student
|
||||
/// brings a paper to office hours, and working it out by hand from a seal is
|
||||
/// the kind of task that gets done wrong once and then trusted.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub printed: Vec<PrintedLetter>,
|
||||
/// How many chose it.
|
||||
pub count: usize,
|
||||
/// The share who chose it.
|
||||
@@ -1159,6 +1230,16 @@ pub struct OptionRow {
|
||||
pub nonfunctioning: bool,
|
||||
}
|
||||
|
||||
/// What one bank option was lettered on one form.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct PrintedLetter {
|
||||
/// The form id.
|
||||
pub form: String,
|
||||
/// The letter this option carried on that form's paper.
|
||||
pub letter: String,
|
||||
}
|
||||
|
||||
/// One form's summary.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
@@ -1263,9 +1344,9 @@ pub struct TriageRow {
|
||||
/// Upper minus lower group.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub discrimination: Option<f64>,
|
||||
/// What the question measured, in the course's words.
|
||||
/// What the question measured, in the course's words: its learning targets.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub objectives: Vec<String>,
|
||||
pub targets: Vec<String>,
|
||||
/// Where it was taught.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub taught_in: Vec<String>,
|
||||
@@ -1390,17 +1471,22 @@ pub fn cohort(
|
||||
.collect();
|
||||
|
||||
// Objective counts come from the responses so that an objective assessed by
|
||||
// two items is not reported as if it had one.
|
||||
// two items is not reported as if it had one. Counted per objective, and by
|
||||
// item number, so a question tagged with two of that objective's targets is
|
||||
// one item here.
|
||||
let mut objective_items: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
|
||||
for row in set.rows.iter().filter(|r| r.counts()) {
|
||||
for objective in &row.learning_objectives {
|
||||
for objective in &row.learning_targets {
|
||||
objective_items
|
||||
.entry(objective.clone())
|
||||
.entry(course.objective_for(objective).to_string())
|
||||
.or_default()
|
||||
.insert(row.item_number);
|
||||
}
|
||||
}
|
||||
|
||||
// Objectives, not targets: this table is the reteaching queue, and it is
|
||||
// only usable if it is short enough to read and each row rests on enough
|
||||
// items to believe.
|
||||
let mut objectives: Vec<CohortObjectiveRow> = cohort
|
||||
.objective_rates
|
||||
.iter()
|
||||
@@ -1410,7 +1496,7 @@ pub fn cohort(
|
||||
let mut not_yet = 0;
|
||||
let mut thin = 0;
|
||||
for student in &cohort.students {
|
||||
if let Some(row) = student.objectives.iter().find(|o| &o.objective == id) {
|
||||
if let Some(row) = student.objectives.iter().find(|o| &o.id == id) {
|
||||
match row.status {
|
||||
Mastery::Meeting => meeting += 1,
|
||||
Mastery::Developing => developing += 1,
|
||||
@@ -1421,7 +1507,7 @@ pub fn cohort(
|
||||
}
|
||||
CohortObjectiveRow {
|
||||
id: id.clone(),
|
||||
text: course.objective_text(id),
|
||||
text: course.text_for(id),
|
||||
n_items: objective_items.get(id).map(|s| s.len()).unwrap_or(0),
|
||||
rate: *rate,
|
||||
meeting,
|
||||
@@ -1451,23 +1537,29 @@ pub fn cohort(
|
||||
.map(|p| {
|
||||
(
|
||||
p.number,
|
||||
(p.level.map(|l| l.code()), p.learning_objectives.clone()),
|
||||
(p.level.map(|l| l.code()), p.learning_targets.clone()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// The printed lettering per form, computed from the same two functions the
|
||||
// exporter and the seal use, so the letters here are the letters on the
|
||||
// paper rather than a second guess at them.
|
||||
let forms: Vec<&crate::assessment::Form> = record.forms.iter().collect();
|
||||
|
||||
let questions: Vec<CohortQuestionRow> = analysis
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let meta = item_meta.get(&item.number);
|
||||
let entry = item.item_ref.as_deref().and_then(|uid| catalog.get(uid));
|
||||
CohortQuestionRow {
|
||||
number: item.number,
|
||||
item: item.item_ref.clone(),
|
||||
level: meta.and_then(|m| m.0),
|
||||
objectives: meta.map(|m| m.1.clone()).unwrap_or_default(),
|
||||
objective_texts: meta
|
||||
.map(|m| m.1.iter().map(|id| course.objective_text(id)).collect())
|
||||
targets: meta.map(|m| m.1.clone()).unwrap_or_default(),
|
||||
target_texts: meta
|
||||
.map(|m| m.1.iter().map(|id| course.text_for(id)).collect())
|
||||
.unwrap_or_default(),
|
||||
taught_in: item
|
||||
.item_ref
|
||||
@@ -1487,6 +1579,9 @@ pub fn cohort(
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
dropped: false,
|
||||
dropped_full_credit: false,
|
||||
stem: entry.map(|e| e.item.stem.clone()),
|
||||
difficulty_band: difficulty_band(item.p_value).to_string(),
|
||||
discrimination_band: discrimination_band(item.point_biserial).to_string(),
|
||||
p_value: item.p_value,
|
||||
@@ -1498,6 +1593,23 @@ pub fn cohort(
|
||||
.options
|
||||
.values()
|
||||
.map(|option| OptionRow {
|
||||
text: entry.and_then(|e| {
|
||||
e.item
|
||||
.options
|
||||
.iter()
|
||||
.find(|o| o.id == option.letter)
|
||||
.map(|o| o.text.clone())
|
||||
}),
|
||||
printed: entry
|
||||
.map(|e| {
|
||||
printed_letters(
|
||||
&forms,
|
||||
&e.item,
|
||||
item.item_ref.as_deref(),
|
||||
&option.letter,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
letter: option.letter.clone(),
|
||||
count: option.count,
|
||||
rate: option.rate,
|
||||
@@ -1536,6 +1648,106 @@ pub fn cohort(
|
||||
.collect();
|
||||
dropped_questions.sort_by_key(|d| d.number);
|
||||
|
||||
// The dropped items, described from the responses rather than from the
|
||||
// scoring. Dropping an item overrides its credit, so p, r, and D are
|
||||
// meaningless for it and are left out. What students actually marked is
|
||||
// untouched by the drop, and that spread is the evidence that justified it.
|
||||
let mut dropped_detail: Vec<CohortQuestionRow> = dropped_questions
|
||||
.iter()
|
||||
.map(|dropped| {
|
||||
let placement = record.placement(dropped.number);
|
||||
let uid = placement.map(|p| p.item.clone());
|
||||
let entry = uid.as_deref().and_then(|uid| catalog.get(uid));
|
||||
let responses = set.for_item(dropped.number);
|
||||
let answered = responses
|
||||
.iter()
|
||||
.filter(|r| !r.chosen().is_empty())
|
||||
.count()
|
||||
.max(1);
|
||||
let keyed: BTreeSet<String> = placement
|
||||
.map(|p| p.key.iter().cloned().collect())
|
||||
.filter(|k: &BTreeSet<String>| !k.is_empty())
|
||||
.or_else(|| entry.map(|e| e.item.key_letters().into_iter().collect()))
|
||||
.unwrap_or_default();
|
||||
|
||||
// One row per option the item declares, so an option nobody
|
||||
// marked still shows as unchosen rather than vanishing.
|
||||
let options: Vec<OptionRow> = entry
|
||||
.map(|e| {
|
||||
e.item
|
||||
.options
|
||||
.iter()
|
||||
.map(|option| {
|
||||
let count = responses
|
||||
.iter()
|
||||
.filter(|r| r.chosen().contains(&option.id))
|
||||
.count();
|
||||
OptionRow {
|
||||
text: Some(option.text.clone()),
|
||||
printed: printed_letters(
|
||||
&forms,
|
||||
&e.item,
|
||||
uid.as_deref(),
|
||||
&option.id,
|
||||
),
|
||||
letter: option.id.clone(),
|
||||
count,
|
||||
rate: count as f64 / answered as f64,
|
||||
is_key: keyed.contains(&option.id),
|
||||
point_biserial: None,
|
||||
nonfunctioning: false,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let targets = placement
|
||||
.map(|p| p.learning_targets.clone())
|
||||
.unwrap_or_default();
|
||||
CohortQuestionRow {
|
||||
number: dropped.number,
|
||||
item: uid.clone(),
|
||||
level: placement.and_then(|p| p.level).map(|l| l.code()),
|
||||
target_texts: targets.iter().map(|id| course.text_for(id)).collect(),
|
||||
targets,
|
||||
dropped: true,
|
||||
dropped_full_credit: dropped.full_credit,
|
||||
stem: entry.map(|e| e.item.stem.clone()),
|
||||
taught_in: uid
|
||||
.as_deref()
|
||||
.map(|uid| taught_in(catalog, uid))
|
||||
.unwrap_or_default(),
|
||||
lectures: entry
|
||||
.map(|e| e.item.sources.iter().map(|s| s.lecture.clone()).collect())
|
||||
.unwrap_or_default(),
|
||||
difficulty_band: String::new(),
|
||||
discrimination_band: String::new(),
|
||||
// The keyed share before the override, which is the closest
|
||||
// honest reading of how the item performed. It is not a
|
||||
// p-value: it counts marks, not credit.
|
||||
p_value: options
|
||||
.iter()
|
||||
.filter(|o| o.is_key)
|
||||
.map(|o| o.rate)
|
||||
.sum::<f64>()
|
||||
.min(1.0),
|
||||
point_biserial: None,
|
||||
discrimination: None,
|
||||
blank_rate: responses.iter().filter(|r| r.chosen().is_empty()).count() as f64
|
||||
/ responses.len().max(1) as f64,
|
||||
key: keyed.iter().cloned().collect(),
|
||||
options,
|
||||
flags: Vec::new(),
|
||||
notes: Vec::new(),
|
||||
prediction_notes: Vec::new(),
|
||||
calibrated: false,
|
||||
by_form: BTreeMap::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
dropped_detail.sort_by_key(|q| q.number);
|
||||
|
||||
let default_options = course.policy.options_per_item;
|
||||
let triage = triage(&questions, threshold, default_options);
|
||||
let predictions = prediction_summary(analysis);
|
||||
@@ -1563,8 +1775,9 @@ pub fn cohort(
|
||||
gaps,
|
||||
questions,
|
||||
revise,
|
||||
dropped_detail,
|
||||
forms: form_rows(set, cohort),
|
||||
blueprint: crate::select::check_blueprint(record),
|
||||
blueprint: crate::select::check_blueprint(record, course),
|
||||
patterns: cohort
|
||||
.archetypes
|
||||
.iter()
|
||||
@@ -1623,6 +1836,53 @@ fn taught_in(catalog: &Catalog, uid: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where one bank option landed on each printed form.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `forms` - the record's forms, in declaration order.
|
||||
/// * `item` - the bank item, for its option count and lettering.
|
||||
/// * `uid` - the item's global id, which salts the permutation.
|
||||
/// * `letter` - the bank letter to locate.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// One entry per form that permutes its options. Forms printing the bank order
|
||||
/// unchanged are left out, since an entry saying C was printed as C is noise on
|
||||
/// every row.
|
||||
fn printed_letters(
|
||||
forms: &[&crate::assessment::Form],
|
||||
item: &crate::item::Item,
|
||||
uid: Option<&str>,
|
||||
letter: &str,
|
||||
) -> Vec<PrintedLetter> {
|
||||
let Some(uid) = uid else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(source) = item.options.iter().position(|o| o.id == letter) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let n = item.options.len();
|
||||
let mut out = Vec::new();
|
||||
for form in forms {
|
||||
if !form.shuffle_options {
|
||||
continue;
|
||||
}
|
||||
// The same permutation the exporter and the seal use, so these are the
|
||||
// letters on the paper rather than a second guess at them.
|
||||
let order = crate::select::option_order(form, uid, n);
|
||||
// `order[position] == source` means the option printed in that slot is
|
||||
// the one being asked about.
|
||||
if let Some(position) = order.iter().position(|index| *index == source) {
|
||||
out.push(PrintedLetter {
|
||||
form: form.id.clone(),
|
||||
letter: crate::seal::printed_letter(position),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The difficulty band a p-value falls in.
|
||||
///
|
||||
/// Three bands rather than five. The only distinction that changes what you do
|
||||
@@ -1727,29 +1987,32 @@ fn lecture_rows(
|
||||
) -> Vec<CohortLectureRow> {
|
||||
let course = &catalog.course;
|
||||
let mut items: BTreeMap<String, Vec<&CohortQuestionRow>> = BTreeMap::new();
|
||||
// Keyed by objective, not by the target an item was tagged with: the rows
|
||||
// this is matched against are objective rows, so collecting target ids here
|
||||
// left every lookup empty and every count zero.
|
||||
let mut lecture_objectives: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
||||
|
||||
for question in questions {
|
||||
// The same two routes the student report uses: the item knows which
|
||||
// lecture it was written from, and the objective registry knows which
|
||||
// lectures develop it.
|
||||
// lecture it was written from, and the registry knows which lectures
|
||||
// develop the target.
|
||||
let mut lectures: BTreeSet<String> = BTreeSet::new();
|
||||
if let Some(entry) = question.item.as_deref().and_then(|uid| catalog.get(uid)) {
|
||||
for source in &entry.item.sources {
|
||||
lectures.insert(source.lecture.clone());
|
||||
}
|
||||
}
|
||||
for objective in &question.objectives {
|
||||
if let Some(entry) = course.learning_objectives.get(objective) {
|
||||
lectures.extend(entry.lectures.iter().cloned());
|
||||
}
|
||||
for target in &question.targets {
|
||||
lectures.extend(course.lectures_for(target).iter().cloned());
|
||||
}
|
||||
for lecture in lectures {
|
||||
items.entry(lecture.clone()).or_default().push(question);
|
||||
lecture_objectives
|
||||
.entry(lecture)
|
||||
.or_default()
|
||||
.extend(question.objectives.iter().cloned());
|
||||
lecture_objectives.entry(lecture).or_default().extend(
|
||||
question
|
||||
.targets
|
||||
.iter()
|
||||
.map(|t| course.objective_for(t).to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1863,7 +2126,7 @@ fn triage(questions: &[CohortQuestionRow], threshold: f64, default_options: usiz
|
||||
p_value: question.p_value,
|
||||
point_biserial: question.point_biserial,
|
||||
discrimination: question.discrimination,
|
||||
objectives: question.objective_texts.clone(),
|
||||
targets: question.target_texts.clone(),
|
||||
taught_in: question.taught_in.clone(),
|
||||
option: option.map(|o| o.letter.clone()),
|
||||
option_share: option.map(|o| o.rate),
|
||||
|
||||
+326
-60
@@ -24,6 +24,28 @@
|
||||
//! uses the interval, so it is honest). A student can be "meeting" an objective
|
||||
//! provisionally, and the report says so.
|
||||
//!
|
||||
//! # Which tier gets classified
|
||||
//!
|
||||
//! The registry has two tiers, objectives and their targets (see
|
||||
//! [`crate::course::Objective`]), and they are reported differently because the
|
||||
//! evidence behind them differs in kind. An **objective** is classified: its
|
||||
//! denominator is every item tagged to any of its targets, which is how an exam
|
||||
//! that spends twelve questions across a topic gets to make one statement with a
|
||||
//! real denominator instead of twelve statements with none.
|
||||
//!
|
||||
//! A **target** is not classified. It usually carries one or two items, and
|
||||
//! `min_items_for_mastery` would mark almost all of them "not enough evidence",
|
||||
//! which would be true but useless. So target rows report the observed rate as
|
||||
//! itemized evidence for the objective's classification, and a report should
|
||||
//! present them that way: not "you have not mastered this" but "here is what you
|
||||
//! missed inside the objective above".
|
||||
//!
|
||||
//! An item tagged with two targets of the same objective counts *once* toward
|
||||
//! that objective. Double counting is right across unrelated objectives, where
|
||||
//! the question "how is this student doing on kinetics" should use every item
|
||||
//! that measured kinetics, but within one denominator it would inflate both the
|
||||
//! count and the confidence.
|
||||
//!
|
||||
//! # Comparison to the cohort
|
||||
//!
|
||||
//! Per-level performance is reported against the class rather than in absolute
|
||||
@@ -33,10 +55,10 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::course::{CourseFile, Policy};
|
||||
use crate::course::CourseFile;
|
||||
use crate::responses::{Response, ResponseSet};
|
||||
use crate::rng::Rng;
|
||||
use crate::taxonomy::Level;
|
||||
use crate::taxonomy::{Level, Tier};
|
||||
|
||||
/// How well a student has met one objective.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -74,14 +96,27 @@ impl Mastery {
|
||||
}
|
||||
}
|
||||
|
||||
/// One student's standing on one objective.
|
||||
/// One student's standing on one registry entry, at either tier.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ObjectiveMastery {
|
||||
/// The objective id.
|
||||
pub objective: String,
|
||||
/// The registry id this row reports on, at either tier.
|
||||
pub id: String,
|
||||
/// The objective text, for reports.
|
||||
pub text: String,
|
||||
/// How many items on this objective the student saw.
|
||||
/// Which tier this row is, since only one of them is a classification.
|
||||
pub tier: Tier,
|
||||
/// The objective this row sits under, for a target row.
|
||||
pub objective: Option<String>,
|
||||
/// For an objective row, how many of its targets the exam reached.
|
||||
///
|
||||
/// A student report can say "four of the nine things under this objective
|
||||
/// were tested", which is the honest scope of the claim. Zero for a target
|
||||
/// row and for an objective with no targets.
|
||||
pub targets_seen: usize,
|
||||
/// For an objective row, how many targets it has in the registry.
|
||||
pub targets_total: usize,
|
||||
/// How many items the student saw. For an objective row, items tagged to any
|
||||
/// of its targets, counted once each.
|
||||
pub n_items: usize,
|
||||
/// How many they got right, counting partial credit.
|
||||
pub credit: f64,
|
||||
@@ -145,8 +180,8 @@ pub struct MissedItem {
|
||||
pub credit: f64,
|
||||
/// The level.
|
||||
pub level: Option<Level>,
|
||||
/// The objectives involved.
|
||||
pub learning_objectives: Vec<String>,
|
||||
/// The learning targets the question measured.
|
||||
pub learning_targets: Vec<String>,
|
||||
/// The misconception the chosen distractor was written to detect.
|
||||
pub misconception: Option<String>,
|
||||
/// Feedback written for a student who chose that option.
|
||||
@@ -208,7 +243,13 @@ impl StudentSummary {
|
||||
pub struct Cohort {
|
||||
/// Per-student summaries, sorted by key.
|
||||
pub students: Vec<StudentSummary>,
|
||||
/// Class rate per objective.
|
||||
/// Class rate per target, which is the tier items are tagged at. Use it to
|
||||
/// drill into an objective the class missed.
|
||||
pub target_rates: BTreeMap<String, f64>,
|
||||
/// Class rate per objective, with each item counted once.
|
||||
///
|
||||
/// This is the class-level table worth acting on, and the one
|
||||
/// [`Cohort::class_gaps`] is drawn from.
|
||||
pub objective_rates: BTreeMap<String, f64>,
|
||||
/// Class rate per level.
|
||||
pub level_rates: BTreeMap<Level, f64>,
|
||||
@@ -218,6 +259,11 @@ pub struct Cohort {
|
||||
pub sd_percent: f64,
|
||||
/// Objectives the class as a whole did not meet, worst first. This is the
|
||||
/// list that should change what you reteach.
|
||||
///
|
||||
/// Objectives rather than targets, because a list of forty targets below
|
||||
/// threshold is a list nobody reteaches from, and because a target that
|
||||
/// carried one item on this exam does not support the claim that the class
|
||||
/// missed it.
|
||||
pub class_gaps: Vec<(String, f64)>,
|
||||
/// Optional grouping of students by response profile.
|
||||
pub archetypes: Vec<Archetype>,
|
||||
@@ -289,7 +335,8 @@ pub fn summarize(
|
||||
let students = set.students();
|
||||
|
||||
// Class rates first: every student's report is relative to these.
|
||||
let objective_rates = rates_by_objective(&set.rows.iter().collect::<Vec<_>>());
|
||||
let target_rates = rates_by_target(&set.rows.iter().collect::<Vec<_>>());
|
||||
let objective_rates = rates_by_objective(&set.rows.iter().collect::<Vec<_>>(), course);
|
||||
let level_rates = rates_by_level(&set.rows.iter().collect::<Vec<_>>());
|
||||
|
||||
// Per-level spread across students, for the z comparisons.
|
||||
@@ -345,28 +392,62 @@ pub fn summarize(
|
||||
.count();
|
||||
let n_items = rows.iter().filter(|r| r.counts()).count();
|
||||
|
||||
// Objectives, in the course's declared order so reports read the way the
|
||||
// course is taught rather than alphabetically.
|
||||
let per_objective = rates_by_objective(&rows);
|
||||
let counts = counts_by_objective(&rows);
|
||||
// The registry in the course's declared order, so a report reads the way
|
||||
// the course is taught rather than alphabetically. Each objective the
|
||||
// exam reached is followed by the targets it reached, which is the order
|
||||
// a report wants them in: the claim, then its evidence.
|
||||
let target_counts = counts_by_target(&rows);
|
||||
let objective_counts = counts_by_objective(&rows, course);
|
||||
let mut objectives = Vec::new();
|
||||
let mut seen: BTreeSet<&String> = BTreeSet::new();
|
||||
for id in order.iter().chain(per_objective.keys()) {
|
||||
if !seen.insert(id) {
|
||||
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||
|
||||
// `order` puts each objective ahead of its own targets, so walking it
|
||||
// produces the tiering. Anything the exam measured that the registry
|
||||
// does not know about is appended afterwards rather than dropped.
|
||||
let measured: Vec<String> = target_counts.keys().cloned().collect();
|
||||
for id in order.iter().cloned().chain(measured) {
|
||||
if !seen.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Some((n, credit)) = counts.get(id).copied() else {
|
||||
continue;
|
||||
};
|
||||
objectives.push(objective_mastery(
|
||||
id,
|
||||
course,
|
||||
n,
|
||||
credit,
|
||||
objective_rates.get(id).copied().unwrap_or(0.0),
|
||||
&rows,
|
||||
policy,
|
||||
));
|
||||
if course.is_objective(&id) {
|
||||
let Some((n, credit)) = objective_counts.get(&id).copied() else {
|
||||
continue;
|
||||
};
|
||||
let targets = course.targets(&id);
|
||||
let reached = targets
|
||||
.iter()
|
||||
.filter(|target| target_counts.contains_key(**target))
|
||||
.count();
|
||||
objectives.push(objective_mastery(
|
||||
&id,
|
||||
course,
|
||||
n,
|
||||
credit,
|
||||
objective_rates.get(&id).copied().unwrap_or(0.0),
|
||||
&rows,
|
||||
policy.min_items_for_mastery.max(1),
|
||||
reached,
|
||||
targets.len(),
|
||||
));
|
||||
} else {
|
||||
let Some((n, credit)) = target_counts.get(&id).copied() else {
|
||||
continue;
|
||||
};
|
||||
// One item is the normal case for a target, so it is reported
|
||||
// rather than withheld. The objective row above it carries the
|
||||
// classification.
|
||||
objectives.push(objective_mastery(
|
||||
&id,
|
||||
course,
|
||||
n,
|
||||
credit,
|
||||
target_rates.get(&id).copied().unwrap_or(0.0),
|
||||
&rows,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Levels.
|
||||
@@ -400,25 +481,29 @@ pub fn summarize(
|
||||
// not: telling a student to review something they may already know costs
|
||||
// them an hour, while telling them they have mastered something they have
|
||||
// not costs them the next exam.
|
||||
//
|
||||
// Both lists are drawn from objective rows only. A focus list built from
|
||||
// targets is as long as the exam and tells a student to review forty
|
||||
// things, which is the same as telling them nothing; the objective list
|
||||
// is short enough to act on, and the target rows underneath it say what
|
||||
// to look at within each one.
|
||||
let strengths: Vec<String> = objectives
|
||||
.iter()
|
||||
.filter(|o| o.status == Mastery::Meeting && o.confident)
|
||||
.map(|o| o.objective.clone())
|
||||
.filter(|o| o.tier == Tier::Objective && o.status == Mastery::Meeting && o.confident)
|
||||
.map(|o| o.id.clone())
|
||||
.collect();
|
||||
let mut focus_pairs: Vec<(&ObjectiveMastery, f64)> = objectives
|
||||
.iter()
|
||||
.filter(|o| o.tier == Tier::Objective)
|
||||
.filter(|o| matches!(o.status, Mastery::NotYet | Mastery::Developing))
|
||||
.map(|o| (o, o.rate))
|
||||
.collect();
|
||||
focus_pairs.sort_by(|a, b| {
|
||||
a.1.partial_cmp(&b.1)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| a.0.objective.cmp(&b.0.objective))
|
||||
.then_with(|| a.0.id.cmp(&b.0.id))
|
||||
});
|
||||
let focus: Vec<String> = focus_pairs
|
||||
.iter()
|
||||
.map(|(o, _)| o.objective.clone())
|
||||
.collect();
|
||||
let focus: Vec<String> = focus_pairs.iter().map(|(o, _)| o.id.clone()).collect();
|
||||
|
||||
let missed = missed_items(&rows, catalog, course);
|
||||
|
||||
@@ -460,6 +545,7 @@ pub fn summarize(
|
||||
|
||||
Cohort {
|
||||
students: summaries,
|
||||
target_rates,
|
||||
objective_rates,
|
||||
level_rates,
|
||||
mean_percent,
|
||||
@@ -469,21 +555,26 @@ pub fn summarize(
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds one objective's mastery record.
|
||||
/// Builds one objective's record, at either tier.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `id` - the objective id.
|
||||
/// * `course` - the course, for text and policy.
|
||||
/// * `n` - items on this objective.
|
||||
/// * `course` - the course, for text, tier, and policy.
|
||||
/// * `n` - items counting toward this row.
|
||||
/// * `credit` - total credit earned.
|
||||
/// * `cohort_rate` - the class rate.
|
||||
/// * `cohort_rate` - the class rate for the same row.
|
||||
/// * `rows` - the student's responses, for the level list.
|
||||
/// * `policy` - the course policy.
|
||||
/// * `min_items` - items required before the row is classified. The policy's
|
||||
/// `min_items_for_mastery` for an objective row, and 1 for a target row, which
|
||||
/// is evidence rather than a classification.
|
||||
/// * `targets_seen` - targets this exam reached, for an objective row.
|
||||
/// * `targets_total` - targets in the registry, for an objective row.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The record.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn objective_mastery(
|
||||
id: &str,
|
||||
course: &CourseFile,
|
||||
@@ -491,12 +582,15 @@ fn objective_mastery(
|
||||
credit: f64,
|
||||
cohort_rate: f64,
|
||||
rows: &[&Response],
|
||||
policy: &Policy,
|
||||
min_items: usize,
|
||||
targets_seen: usize,
|
||||
targets_total: usize,
|
||||
) -> ObjectiveMastery {
|
||||
let policy = &course.policy;
|
||||
let rate = if n > 0 { credit / n as f64 } else { 0.0 };
|
||||
let (lower, upper) = wilson(credit, n, 1.96);
|
||||
|
||||
let status = if n < policy.min_items_for_mastery.max(1) {
|
||||
let status = if n < min_items.max(1) {
|
||||
Mastery::NotEnoughEvidence
|
||||
} else if rate >= policy.mastery_threshold {
|
||||
Mastery::Meeting
|
||||
@@ -506,17 +600,35 @@ fn objective_mastery(
|
||||
Mastery::NotYet
|
||||
};
|
||||
|
||||
// Levels the row was assessed at. For an objective row this is every level
|
||||
// any of its targets was assessed at, which is what makes "met this
|
||||
// objective" a checkable claim: meeting it on three Remember items is a
|
||||
// different statement from meeting it on three Analyze items.
|
||||
let levels: Vec<Level> = rows
|
||||
.iter()
|
||||
.filter(|r| r.learning_objectives.iter().any(|o| o == id))
|
||||
.filter(|r| {
|
||||
r.learning_targets
|
||||
.iter()
|
||||
.any(|t| t == id || course.objective_for(t) == id)
|
||||
})
|
||||
.filter_map(|r| r.level)
|
||||
.collect::<BTreeSet<Level>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
ObjectiveMastery {
|
||||
objective: id.to_string(),
|
||||
text: course.objective_text(id),
|
||||
id: id.to_string(),
|
||||
text: course.text_for(id),
|
||||
tier: if course.is_objective(id) {
|
||||
Tier::Objective
|
||||
} else {
|
||||
Tier::Target
|
||||
},
|
||||
objective: course
|
||||
.is_target(id)
|
||||
.then(|| course.objective_for(id).to_string()),
|
||||
targets_seen,
|
||||
targets_total,
|
||||
n_items: n,
|
||||
credit,
|
||||
rate,
|
||||
@@ -595,7 +707,7 @@ fn missed_items(
|
||||
selected: r.chosen().to_vec(),
|
||||
credit: r.credit,
|
||||
level: r.level,
|
||||
learning_objectives: r.learning_objectives.clone(),
|
||||
learning_targets: r.learning_targets.clone(),
|
||||
misconception,
|
||||
feedback,
|
||||
study,
|
||||
@@ -612,9 +724,9 @@ fn missed_items(
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The rate for each objective mentioned.
|
||||
pub fn rates_by_objective(rows: &[&Response]) -> BTreeMap<String, f64> {
|
||||
counts_by_objective(rows)
|
||||
/// The rate for each target mentioned.
|
||||
pub fn rates_by_target(rows: &[&Response]) -> BTreeMap<String, f64> {
|
||||
counts_by_target(rows)
|
||||
.into_iter()
|
||||
.map(|(id, (n, credit))| {
|
||||
let rate = if n > 0 { credit / n as f64 } else { 0.0 };
|
||||
@@ -623,11 +735,12 @@ pub fn rates_by_objective(rows: &[&Response]) -> BTreeMap<String, f64> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Item counts and credit per objective.
|
||||
/// Item counts and credit per target, as tagged.
|
||||
///
|
||||
/// An item tagged with two objectives counts toward both. That double counting is
|
||||
/// An item tagged with two targets counts toward both. That double counting is
|
||||
/// intentional: the question "how is this student doing on kinetics" should use
|
||||
/// every item that measured kinetics.
|
||||
/// every item that measured kinetics. Roll-up to the objective, where the same
|
||||
/// item must count once, is [`counts_by_objective`].
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
@@ -635,14 +748,14 @@ pub fn rates_by_objective(rows: &[&Response]) -> BTreeMap<String, f64> {
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `(item count, total credit)` per objective.
|
||||
pub fn counts_by_objective(rows: &[&Response]) -> BTreeMap<String, (usize, f64)> {
|
||||
/// `(item count, total credit)` per target.
|
||||
pub fn counts_by_target(rows: &[&Response]) -> BTreeMap<String, (usize, f64)> {
|
||||
let mut out: BTreeMap<String, (usize, f64)> = BTreeMap::new();
|
||||
for r in rows {
|
||||
if !r.counts() {
|
||||
continue;
|
||||
}
|
||||
for objective in &r.learning_objectives {
|
||||
for objective in &r.learning_targets {
|
||||
let e = out.entry(objective.clone()).or_insert((0, 0.0));
|
||||
e.0 += 1;
|
||||
e.1 += r.credit.clamp(0.0, 1.0);
|
||||
@@ -651,6 +764,66 @@ pub fn counts_by_objective(rows: &[&Response]) -> BTreeMap<String, (usize, f64)>
|
||||
out
|
||||
}
|
||||
|
||||
/// Item counts and credit per objective.
|
||||
///
|
||||
/// Each response contributes at most once to any one objective, even when it is
|
||||
/// tagged with several of that objective's targets. Within a single denominator,
|
||||
/// counting an item twice would inflate both the rate's weight and the
|
||||
/// confidence interval's tightness, and the interval is the part of the report
|
||||
/// that is supposed to stay honest. Across unrelated objectives an item still
|
||||
/// counts toward each, as it does in [`counts_by_target`].
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `rows` - the responses.
|
||||
/// * `course` - the course, for the objective each tagged target belongs to.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `(item count, total credit)` per objective.
|
||||
pub fn counts_by_objective(
|
||||
rows: &[&Response],
|
||||
course: &CourseFile,
|
||||
) -> BTreeMap<String, (usize, f64)> {
|
||||
let mut out: BTreeMap<String, (usize, f64)> = BTreeMap::new();
|
||||
for r in rows {
|
||||
if !r.counts() {
|
||||
continue;
|
||||
}
|
||||
let objectives: BTreeSet<&str> = r
|
||||
.learning_targets
|
||||
.iter()
|
||||
.map(|t| course.objective_for(t))
|
||||
.collect();
|
||||
for id in objectives {
|
||||
let e = out.entry(id.to_string()).or_insert((0, 0.0));
|
||||
e.0 += 1;
|
||||
e.1 += r.credit.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Credit rate per objective.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `rows` - the responses.
|
||||
/// * `course` - the course, for the objective each tagged target belongs to.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The rate for each objective the responses reached.
|
||||
pub fn rates_by_objective(rows: &[&Response], course: &CourseFile) -> BTreeMap<String, f64> {
|
||||
counts_by_objective(rows, course)
|
||||
.into_iter()
|
||||
.map(|(id, (n, credit))| {
|
||||
let rate = if n > 0 { credit / n as f64 } else { 0.0 };
|
||||
(id, rate)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Credit rate per level.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -1007,21 +1180,113 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_counts_credit_every_tagged_item() {
|
||||
fn target_counts_credit_every_tagged_item() {
|
||||
let rows = [
|
||||
make("s1", 1, 1.0, &["lo-a", "lo-b"], Some(Level::Remember)),
|
||||
make("s1", 2, 0.0, &["lo-a"], Some(Level::Apply)),
|
||||
];
|
||||
let refs: Vec<&Response> = rows.iter().collect();
|
||||
let counts = counts_by_objective(&refs);
|
||||
let counts = counts_by_target(&refs);
|
||||
// lo-a saw both items; lo-b only the first.
|
||||
assert_eq!(counts["lo-a"], (2, 1.0));
|
||||
assert_eq!(counts["lo-b"], (1, 1.0));
|
||||
let rates = rates_by_objective(&refs);
|
||||
let rates = rates_by_target(&refs);
|
||||
assert_eq!(rates["lo-a"], 0.5);
|
||||
assert_eq!(rates["lo-b"], 1.0);
|
||||
}
|
||||
|
||||
/// A course with one objective over three targets, plus an objective with
|
||||
/// no targets of its own.
|
||||
fn tiered_course() -> CourseFile {
|
||||
serde_yaml_ng::from_str(
|
||||
r#"
|
||||
course: { code: X, title: Y, term: Z }
|
||||
policy: { mastery_threshold: 0.75, min_items_for_mastery: 2 }
|
||||
learning_objectives:
|
||||
lo-binding: { text: Quantify binding., order: 1 }
|
||||
lo-standalone: { text: Untiered objective., order: 2 }
|
||||
learning_targets:
|
||||
t-kd: { text: Write the expression., objective: lo-binding, order: 1 }
|
||||
t-plot: { text: Read a plot., objective: lo-binding, order: 2 }
|
||||
t-window: { text: State the switching window., objective: lo-binding, order: 3 }
|
||||
"#,
|
||||
)
|
||||
.expect("course parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_on_targets_roll_up_to_their_objective() {
|
||||
let course = tiered_course();
|
||||
let rows = [
|
||||
make("s1", 1, 1.0, &["t-kd"], Some(Level::Remember)),
|
||||
make("s1", 2, 0.0, &["t-plot"], Some(Level::Apply)),
|
||||
make("s1", 3, 1.0, &["t-window"], Some(Level::Understand)),
|
||||
make("s1", 4, 1.0, &["lo-standalone"], Some(Level::Remember)),
|
||||
];
|
||||
let refs: Vec<&Response> = rows.iter().collect();
|
||||
|
||||
let objectives = counts_by_objective(&refs, &course);
|
||||
// Three items, two credited, in one denominator.
|
||||
assert_eq!(objectives["lo-binding"], (3, 2.0));
|
||||
assert_eq!(objectives["lo-standalone"], (1, 1.0));
|
||||
// The targets are not themselves objective rows.
|
||||
assert!(!objectives.contains_key("t-kd"));
|
||||
|
||||
// As-tagged counts are still available for the drill-down.
|
||||
let tagged = counts_by_target(&refs);
|
||||
assert_eq!(tagged["t-kd"], (1, 1.0));
|
||||
assert_eq!(tagged.len(), 4);
|
||||
|
||||
let rates = rates_by_objective(&refs, &course);
|
||||
assert!((rates["lo-binding"] - 2.0 / 3.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_item_counts_once_toward_its_objective() {
|
||||
let course = tiered_course();
|
||||
// A single question tagged with two targets of the same objective.
|
||||
let rows = [make(
|
||||
"s1",
|
||||
1,
|
||||
0.0,
|
||||
&["t-kd", "t-plot"],
|
||||
Some(Level::Understand),
|
||||
)];
|
||||
let refs: Vec<&Response> = rows.iter().collect();
|
||||
|
||||
let objectives = counts_by_objective(&refs, &course);
|
||||
assert_eq!(
|
||||
objectives["lo-binding"],
|
||||
(1, 0.0),
|
||||
"one question is one item in the objective's denominator"
|
||||
);
|
||||
// Whereas as-tagged counting credits both targets, as it always has.
|
||||
let tagged = counts_by_target(&refs);
|
||||
assert_eq!(tagged["t-kd"], (1, 0.0));
|
||||
assert_eq!(tagged["t-plot"], (1, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untiered_course_rolls_up_to_itself() {
|
||||
// Adopting the second tier is optional: with no targets declared, every
|
||||
// entry is an objective and the rolled-up counts equal the tagged ones.
|
||||
let course: CourseFile = serde_yaml_ng::from_str(
|
||||
r#"
|
||||
course: { code: X, title: Y, term: Z }
|
||||
learning_objectives:
|
||||
lo-a: { text: A }
|
||||
lo-b: { text: B }
|
||||
"#,
|
||||
)
|
||||
.expect("course parses");
|
||||
let rows = [
|
||||
make("s1", 1, 1.0, &["lo-a", "lo-b"], Some(Level::Remember)),
|
||||
make("s1", 2, 0.0, &["lo-a"], Some(Level::Apply)),
|
||||
];
|
||||
let refs: Vec<&Response> = rows.iter().collect();
|
||||
assert_eq!(counts_by_objective(&refs, &course), counts_by_target(&refs));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_rates_ignore_untagged_items() {
|
||||
let rows = [
|
||||
@@ -1098,6 +1363,7 @@ mod tests {
|
||||
item_number: number,
|
||||
item_ref: None,
|
||||
item_version: None,
|
||||
variant: None,
|
||||
selected: vec!["A".into()],
|
||||
selected_source: vec![],
|
||||
eliminated: vec![],
|
||||
@@ -1108,7 +1374,7 @@ mod tests {
|
||||
score: credit,
|
||||
response_time_seconds: None,
|
||||
level,
|
||||
learning_objectives: objectives.iter().map(|s| s.to_string()).collect(),
|
||||
learning_targets: objectives.iter().map(|s| s.to_string()).collect(),
|
||||
topics: vec![],
|
||||
bonus: false,
|
||||
dropped: false,
|
||||
|
||||
+420
-30
@@ -31,6 +31,16 @@ const BASE: &str = "https://coursebank.dev/schema";
|
||||
pub enum Kind {
|
||||
/// `course.yaml`.
|
||||
Course,
|
||||
/// `references.yaml`.
|
||||
References,
|
||||
/// `lectures/*.yaml`.
|
||||
Lecture,
|
||||
/// `objectives/*.yaml`.
|
||||
Objective,
|
||||
/// `analysis/calibration.yaml`.
|
||||
Calibration,
|
||||
/// `analysis/administrations/*.yaml`.
|
||||
Measurements,
|
||||
/// `banks/*.yaml`.
|
||||
Bank,
|
||||
/// `assessments/*.yaml`.
|
||||
@@ -38,13 +48,27 @@ pub enum Kind {
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
/// All three kinds.
|
||||
pub const ALL: [Kind; 3] = [Kind::Course, Kind::Bank, Kind::Assessment];
|
||||
/// Every kind.
|
||||
pub const ALL: [Kind; 8] = [
|
||||
Kind::Course,
|
||||
Kind::References,
|
||||
Kind::Lecture,
|
||||
Kind::Objective,
|
||||
Kind::Bank,
|
||||
Kind::Assessment,
|
||||
Kind::Calibration,
|
||||
Kind::Measurements,
|
||||
];
|
||||
|
||||
/// The file name a schema is written to.
|
||||
pub fn filename(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Course => "course.schema.json",
|
||||
Kind::References => "references.schema.json",
|
||||
Kind::Lecture => "lecture.schema.json",
|
||||
Kind::Objective => "objective.schema.json",
|
||||
Kind::Calibration => "calibration.schema.json",
|
||||
Kind::Measurements => "measurements.schema.json",
|
||||
Kind::Bank => "bank.schema.json",
|
||||
Kind::Assessment => "assessment.schema.json",
|
||||
}
|
||||
@@ -80,12 +104,17 @@ impl Kind {
|
||||
pub fn schema(kind: Kind) -> Value {
|
||||
match kind {
|
||||
Kind::Course => course_schema(),
|
||||
Kind::References => references_schema(),
|
||||
Kind::Lecture => lecture_fragment_schema(),
|
||||
Kind::Objective => objective_fragment_schema(),
|
||||
Kind::Calibration => calibration_file_schema(),
|
||||
Kind::Measurements => measurements_file_schema(),
|
||||
Kind::Bank => bank_schema(),
|
||||
Kind::Assessment => assessment_schema(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes all three schemas to a directory.
|
||||
/// Writes every schema to a directory.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
@@ -328,6 +357,13 @@ fn lecture_schema() -> Value {
|
||||
"date": date("Date delivered."),
|
||||
"unit": { "type": "string", "description": "Unit id." },
|
||||
"slides_url": { "type": "string" },
|
||||
"teaches": {
|
||||
"type": "array",
|
||||
"description": "The objectives this session develops. Each named objective gains \
|
||||
this lecture in its `lectures` list when the course is loaded, \
|
||||
so the pair is declared once, here, while planning the lecture.",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"readings": {
|
||||
"type": "array",
|
||||
"description": "Readings assigned with this lecture, in the order you assign \
|
||||
@@ -338,6 +374,44 @@ fn lecture_schema() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema for one learning target.
|
||||
fn target_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["text", "objective"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"text": text("The target as a student would read it. Start with a verb."),
|
||||
"objective": {
|
||||
"type": "string",
|
||||
"description": "The objective this target belongs to. Required: a target with \
|
||||
no objective would be measured and never reported."
|
||||
},
|
||||
"lectures": string_array("Lecture ids that cover this."),
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Ignored since 2.0. An objective's targets are a set of question \
|
||||
templates, not steps in a sequence — they are not taught in \
|
||||
order and an exam samples from them — so a position asserts an \
|
||||
order that does not exist. Where one target depends on another, \
|
||||
say so with `prerequisites`. `coursebank migrate order` removes \
|
||||
this."
|
||||
},
|
||||
"level_ceiling": level(),
|
||||
"prerequisites": string_array(
|
||||
"Target or objective ids that must come first. Cycles are rejected."
|
||||
),
|
||||
"tags": string_array("Free-form tags."),
|
||||
"assessed": {
|
||||
"type": "boolean",
|
||||
"description": "Set false for something you teach but do not test. An \
|
||||
unassessed objective exempts its targets regardless."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema for one learning objective.
|
||||
fn objective_schema() -> Value {
|
||||
json!({
|
||||
@@ -351,9 +425,10 @@ fn objective_schema() -> Value {
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Position in teaching order, low first. A lecture page numbers \
|
||||
objectives by this; without it they sort by id, which puts \
|
||||
an objective before its own prerequisite."
|
||||
"description": "Position in teaching order, low first. Derived since 2.0 from \
|
||||
the position of this objective in a lecture's `teaches` list; \
|
||||
an authored value still wins, and `coursebank migrate order` \
|
||||
removes them."
|
||||
},
|
||||
"level_ceiling": level(),
|
||||
"prerequisites": string_array(
|
||||
@@ -363,7 +438,8 @@ fn objective_schema() -> Value {
|
||||
"assessed": {
|
||||
"type": "boolean",
|
||||
"description": "Set false for an objective you teach but do not test; coverage \
|
||||
reporting will stop flagging it as a gap."
|
||||
reporting will stop flagging it as a gap. This exempts its \
|
||||
targets too."
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -399,7 +475,23 @@ fn reference_schema() -> Value {
|
||||
"volume": { "type": "string" },
|
||||
"issue": { "type": "string" },
|
||||
"pages": { "type": "string", "description": "Pages of the work, not of a reading." },
|
||||
"doi": { "type": "string", "description": "Bare DOI: 10.1038/nature12373." },
|
||||
"doi": {
|
||||
"type": "string",
|
||||
"pattern": "^(doi:|https?://(dx\\.)?doi\\.org/)?10\\.",
|
||||
"description": "Bare DOI: 10.1038/nature12373. For a manuscript this is \
|
||||
usually the only link worth storing, since a reading list \
|
||||
resolves it to doi.org."
|
||||
},
|
||||
"arxiv": { "type": "string", "description": "Bare arXiv id: 2301.00001." },
|
||||
"pmcid": {
|
||||
"type": "string",
|
||||
"description": "PubMed Central id, which hosts the full text: PMC3084216."
|
||||
},
|
||||
"pmid": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+$",
|
||||
"description": "PubMed id, which hosts a record about the work: 21471563."
|
||||
},
|
||||
"isbn": { "type": "string" },
|
||||
"url": { "type": "string", "description": "Canonical URL for the whole work." },
|
||||
"base_url": {
|
||||
@@ -444,9 +536,12 @@ fn reading_mapping_schema() -> Value {
|
||||
"enum": strings(&["assigned", "supplemental"]),
|
||||
"description": "supplemental means offered but not separately assessed."
|
||||
},
|
||||
"objectives": string_array(
|
||||
"Objective ids this reading serves. A student who misses one of these is \
|
||||
pointed here, so the list is what makes study guidance specific."
|
||||
"targets": string_array(
|
||||
"Target ids this reading serves. A student who misses one of these is pointed \
|
||||
here, so the list is what makes study guidance specific. Cite targets rather \
|
||||
than objectives: a section of a book backs a specific performance, and a \
|
||||
reading list resolved from an objective would send a student the same six \
|
||||
sections whichever part of it they missed."
|
||||
),
|
||||
"summary": text("What the section contains."),
|
||||
"focus": text("What to take from it. This is the sentence a student report quotes."),
|
||||
@@ -501,10 +596,20 @@ fn course_schema() -> Value {
|
||||
},
|
||||
"learning_objectives": {
|
||||
"type": "object",
|
||||
"description": "Objectives by id. Everything downstream — coverage, mastery, \
|
||||
student reports — keys off these.",
|
||||
"description": "Learning objectives by id, conventionally `lo-...`. The tier a \
|
||||
syllabus lists and a report classifies as met. Objectives only: \
|
||||
the performances they are met by go in `learning_targets`.",
|
||||
"additionalProperties": objective_schema()
|
||||
},
|
||||
"learning_targets": {
|
||||
"type": "object",
|
||||
"description": "Learning targets by id. Each names the objective it belongs to. \
|
||||
This is the tier items are tagged to and readings are cited \
|
||||
against; results roll up to the objective. Ids must not start \
|
||||
with `lo`, so that an id in an item or a report says which tier \
|
||||
it belongs to without a lookup — `t-...` is the convention.",
|
||||
"additionalProperties": target_schema()
|
||||
},
|
||||
"stimuli": {
|
||||
"type": "object",
|
||||
"description": "Shared passages, figures, or data that several items refer to.",
|
||||
@@ -520,6 +625,94 @@ fn course_schema() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema for `references.yaml`.
|
||||
fn references_schema() -> Value {
|
||||
json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": format!("{BASE}/references.schema.json"),
|
||||
"title": "coursebank references file",
|
||||
"description": "The works the course cites, by citation key. One fragment of the \
|
||||
course file; see course.schema.json for the whole.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"type": ["string", "number"],
|
||||
"description": format!("Format version; currently {SCHEMA_VERSION}. Declared in \
|
||||
course.yaml; fragments inherit it.")
|
||||
},
|
||||
"references": {
|
||||
"type": "object",
|
||||
"description": "Works by citation key.",
|
||||
"additionalProperties": reference_schema()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema for one file under `lectures/`.
|
||||
fn lecture_fragment_schema() -> Value {
|
||||
json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": format!("{BASE}/lecture.schema.json"),
|
||||
"title": "coursebank lecture file",
|
||||
"description": "One session: its readings, and the objectives it develops. A fragment \
|
||||
of the course file, merged on load.",
|
||||
"type": "object",
|
||||
"required": ["lectures"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"type": ["string", "number"],
|
||||
"description": "Declared in course.yaml; fragments inherit it."
|
||||
},
|
||||
"lectures": {
|
||||
"type": "object",
|
||||
"description": "Keyed by lecture id, conventionally one entry per file.",
|
||||
"additionalProperties": lecture_schema()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema for one file under `objectives/`.
|
||||
fn objective_fragment_schema() -> Value {
|
||||
json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": format!("{BASE}/objective.schema.json"),
|
||||
"title": "coursebank objective file",
|
||||
"description": "One learning objective and the learning targets it decomposes into. A \
|
||||
fragment of the course file, merged on load.",
|
||||
"type": "object",
|
||||
"required": ["learning_objectives"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"type": ["string", "number"],
|
||||
"description": "Declared in course.yaml; fragments inherit it."
|
||||
},
|
||||
"learning_objectives": {
|
||||
"type": "object",
|
||||
"description": "Keyed by objective id, conventionally one entry per file. Its \
|
||||
`lectures` list is derived from each lecture's `teaches`, so \
|
||||
leave it out unless you prefer to declare it here.",
|
||||
"additionalProperties": objective_schema()
|
||||
},
|
||||
"learning_targets": {
|
||||
"type": "object",
|
||||
"description": "The targets of this file's objective, by id. A target with no \
|
||||
`lectures` of its own inherits its objective's.",
|
||||
"additionalProperties": target_schema()
|
||||
},
|
||||
"stimuli": {
|
||||
"type": "object",
|
||||
"description": "Shared passages, figures, or data that several items refer to.",
|
||||
"additionalProperties": stimulus_schema()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// One option's schema.
|
||||
///
|
||||
/// Split out from [`item_schema`] rather than inlined, because `serde_json`'s
|
||||
@@ -535,9 +728,11 @@ fn option_schema() -> Value {
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-H]$",
|
||||
"description": "Option letter. Identity, not print position — shuffled forms \
|
||||
relabel on the way out."
|
||||
"pattern": "^(o-[a-z0-9]+(-[a-z0-9]+)*|[A-H])$",
|
||||
"description": "Option id, unique within the item: `o-fourth-line`. An \
|
||||
identity, not a print position — shuffled forms relabel on the \
|
||||
way out. A single letter A-H is the pre-2.0 form; \
|
||||
`coursebank migrate options` renames it."
|
||||
},
|
||||
"text": text("The option as a student reads it."),
|
||||
"correct": { "type": "boolean" },
|
||||
@@ -569,7 +764,8 @@ fn option_schema() -> Value {
|
||||
},
|
||||
"selection_rate_expected": proportion(
|
||||
"How often you expect this to be chosen. Compared against reality."
|
||||
)
|
||||
),
|
||||
"retired": retirement_schema()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -764,6 +960,21 @@ fn calibration_schema() -> Value {
|
||||
"additionalProperties": option_stat_schema()
|
||||
},
|
||||
"irt": irt_schema(),
|
||||
"variants": {
|
||||
"type": "array",
|
||||
"description": "One record per option set ever administered. A stem shown with \
|
||||
different distractors is a different item, so a p-value pooled \
|
||||
across both would average two questions.",
|
||||
"items": variant_calibration_schema()
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"description": "One record per option, pooled across every set it appeared in. \
|
||||
Supports one claim — this option draws nobody, anywhere — which \
|
||||
is what retires a distractor and what one administration cannot \
|
||||
show.",
|
||||
"additionalProperties": option_history_schema()
|
||||
},
|
||||
"flags": {
|
||||
"type": "array",
|
||||
"items": { "type": "string", "enum": strings(&flags) }
|
||||
@@ -805,6 +1016,163 @@ fn history_schema() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema for one option set's statistics.
|
||||
fn variant_calibration_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["variant"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"variant": {
|
||||
"type": "string",
|
||||
"description": "Digest of the stem, the administered options, and which was keyed."
|
||||
},
|
||||
"key": string_array("The option ids keyed correct in this set."),
|
||||
"distractors": string_array("The option ids offered alongside them."),
|
||||
"administrations": string_array("The administrations pooled into these numbers."),
|
||||
"n_examinees": { "type": "integer", "minimum": 0 },
|
||||
"p_value": proportion("Proportion correct, for this option set only."),
|
||||
"point_biserial": { "type": "number", "minimum": -1.0, "maximum": 1.0 },
|
||||
"discrimination_index": { "type": "number", "minimum": -1.0, "maximum": 1.0 },
|
||||
"option_stats": {
|
||||
"type": "object",
|
||||
"description": "Per-option behaviour within this set, by option id.",
|
||||
"additionalProperties": option_stat_schema()
|
||||
},
|
||||
"irt": irt_schema(),
|
||||
"flags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": strings(
|
||||
&Flag::ALL.iter().map(|f| f.as_str()).collect::<Vec<&str>>()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema for one option's cross-variant history.
|
||||
fn option_history_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"appearances": { "type": "integer", "minimum": 0 },
|
||||
"n_examinees": { "type": "integer", "minimum": 0 },
|
||||
"mean_selection_rate": proportion(
|
||||
"Mean of the within-variant rates. For reading, not for acting on: each rate is \
|
||||
a share of a different set."
|
||||
),
|
||||
"never_chosen": {
|
||||
"type": "boolean",
|
||||
"description": "Never chosen, anywhere. The claim that justifies retiring it."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema for `analysis/calibration.yaml`.
|
||||
fn calibration_file_schema() -> Value {
|
||||
json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": format!("{BASE}/calibration.schema.json"),
|
||||
"title": "coursebank calibration store",
|
||||
"description": "The pooled statistics, by item id. Kept out of the banks: a bank's diff \
|
||||
should be a change of intent, not the output of a grading run. Committed \
|
||||
— every number here is a cohort aggregate, and there is no field for a \
|
||||
student.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schema_version": { "type": ["string", "number"] },
|
||||
"items": {
|
||||
"type": "object",
|
||||
"description": "Keyed by item id, which since 2.0 names the item course-wide and \
|
||||
carries no file name, so moving a question between banks does \
|
||||
not orphan its statistics.",
|
||||
"additionalProperties": calibration_schema()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema for one file under `analysis/administrations/`.
|
||||
fn measurements_file_schema() -> Value {
|
||||
json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": format!("{BASE}/measurements.schema.json"),
|
||||
"title": "coursebank administration record",
|
||||
"description": "What one administration measured. Written once and not rewritten, like \
|
||||
a seal: it records a thing that happened on a day. Cohort aggregates \
|
||||
only — no per-section or per-student breakdown, because a small cell \
|
||||
crossed with anything else stops being an aggregate.",
|
||||
"type": "object",
|
||||
"required": ["administration"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schema_version": { "type": ["string", "number"] },
|
||||
"administration": {
|
||||
"type": "object",
|
||||
"required": ["id", "assessment", "n_examinees"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"assessment": { "type": "string" },
|
||||
"term": { "type": "string" },
|
||||
"date": date("When it was given."),
|
||||
"forms": string_array("The forms in play."),
|
||||
"n_examinees": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "The number to read before any of the others: a \
|
||||
point-biserial on twenty-seven students is a different \
|
||||
kind of claim than one on three hundred."
|
||||
},
|
||||
"model": { "type": "string" },
|
||||
"generated": date("When the analysis was run."),
|
||||
"coursebank": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["item", "number", "n"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"item": { "type": "string" },
|
||||
"number": { "type": "integer", "minimum": 1 },
|
||||
"variant": { "type": "string" },
|
||||
"stem_digest": { "type": "string" },
|
||||
"n": { "type": "integer", "minimum": 0 },
|
||||
"p_value": proportion("Proportion correct on this administration."),
|
||||
"point_biserial": { "type": "number", "minimum": -1.0, "maximum": 1.0 },
|
||||
"discrimination_index": {
|
||||
"type": "number", "minimum": -1.0, "maximum": 1.0
|
||||
},
|
||||
"option_stats": {
|
||||
"type": "object",
|
||||
"additionalProperties": option_stat_schema()
|
||||
},
|
||||
"irt": irt_schema(),
|
||||
"flags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": strings(
|
||||
&Flag::ALL.iter().map(|f| f.as_str()).collect::<Vec<&str>>()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema for a retirement record.
|
||||
fn retirement_schema() -> Value {
|
||||
json!({
|
||||
@@ -827,9 +1195,12 @@ fn item_identity_properties() -> Value {
|
||||
json!({
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^q-[a-z0-9]+(-[a-z0-9]+)*-[0-9]{3}$",
|
||||
"description": "Item id, e.g. q-glycolysis-014. Stable forever: assessment records \
|
||||
and stored responses refer to it."
|
||||
"pattern": "^q-[a-z0-9]+(-[a-z0-9]+)*$",
|
||||
"description": "Item id, e.g. q-glycolysis-rate-limiting-step. Stable forever: \
|
||||
assessment records and stored responses refer to it, so renaming one \
|
||||
is a migration rather than an edit. A trailing counter is no longer \
|
||||
expected — it recorded when the item was written, which git knows — \
|
||||
but an id that still has one stays valid."
|
||||
},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
@@ -880,9 +1251,11 @@ fn item_content_properties() -> Value {
|
||||
"items": option_schema()
|
||||
},
|
||||
"solution": solution_schema(),
|
||||
"learning_objectives": string_array(
|
||||
"Objective ids this item measures. Reports aggregate on these, so an item with none \
|
||||
contributes to nothing."
|
||||
"learning_targets": string_array(
|
||||
"Target ids this item measures. Reports aggregate these onto the target's objective, \
|
||||
so an item with none contributes to nothing. Name the target rather than the \
|
||||
objective: the objective follows from it, and recording which target was asked \
|
||||
about is what lets a report explain a result instead of only stating it."
|
||||
),
|
||||
"sources": {
|
||||
"type": "array",
|
||||
@@ -894,7 +1267,6 @@ fn item_content_properties() -> Value {
|
||||
"prerequisites": string_array("Objective ids a student needs before this item."),
|
||||
"assets": { "type": "array", "items": asset_schema() },
|
||||
"design": design_schema(),
|
||||
"calibration": calibration_schema(),
|
||||
"review": review_schema(),
|
||||
"history": {
|
||||
"type": "array",
|
||||
@@ -948,7 +1320,7 @@ fn bank_scope_schema() -> Value {
|
||||
outside it.",
|
||||
"properties": {
|
||||
"lectures": string_array("Lecture ids."),
|
||||
"learning_objectives": string_array("Objective ids."),
|
||||
"learning_targets": string_array("Target ids."),
|
||||
"units": string_array("Unit ids."),
|
||||
"topics": string_array("Topics.")
|
||||
}
|
||||
@@ -1044,7 +1416,10 @@ fn blueprint_schema() -> Value {
|
||||
"type": "object",
|
||||
"description": "Minimum items per objective. Placed before level quotas, because \
|
||||
a coverage requirement is the constraint most likely to become \
|
||||
unsatisfiable.",
|
||||
unsatisfiable. A requirement is satisfied by items on any of \
|
||||
that objective's targets, so this stays short: name the dozen \
|
||||
objectives the exam is meant to cover, not the hundred targets \
|
||||
it is built from.",
|
||||
"additionalProperties": { "type": "integer", "minimum": 0 }
|
||||
},
|
||||
"lectures": string_array("Restrict the draw to these lectures."),
|
||||
@@ -1104,9 +1479,23 @@ fn placement_schema() -> Value {
|
||||
},
|
||||
"points": { "type": "number", "minimum": 0.0 },
|
||||
"bonus": { "type": "boolean" },
|
||||
"key": string_array("Keyed option letters as administered."),
|
||||
"key": string_array(
|
||||
"The option ids keyed correct for this administration. One for a \
|
||||
single_best_answer, chosen from the item's pool of defensible keys."
|
||||
),
|
||||
"distractors": string_array(
|
||||
"The option ids offered alongside the key. Resolved when the assessment is \
|
||||
assembled and written out explicitly, so a later bank edit cannot change the \
|
||||
paper. Empty means the whole pool."
|
||||
),
|
||||
"variant": {
|
||||
"type": "string",
|
||||
"description": "Digest of the item as this administration showed it: stem, \
|
||||
administered options, and which was keyed. The key statistics \
|
||||
pool on."
|
||||
},
|
||||
"level": level(),
|
||||
"learning_objectives": string_array("Objectives as administered."),
|
||||
"learning_targets": string_array("Targets as administered."),
|
||||
"credit_overrides": {
|
||||
"type": "object",
|
||||
"description": "Partial credit decided after the fact, by option letter. Recording \
|
||||
@@ -1207,7 +1596,8 @@ mod tests {
|
||||
assert_eq!(props["options"]["maxItems"], 8);
|
||||
assert_eq!(
|
||||
props["options"]["items"]["properties"]["id"]["pattern"],
|
||||
"^[A-H]$"
|
||||
// Either form: the 2.0 name, or the letter it replaces.
|
||||
"^(o-[a-z0-9]+(-[a-z0-9]+)*|[A-H])$"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1230,7 +1620,7 @@ mod tests {
|
||||
let dir = std::env::temp_dir().join(format!("cb-schema-{}", std::process::id()));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
let written = write_all(&dir).unwrap();
|
||||
assert_eq!(written.len(), 3);
|
||||
assert_eq!(written.len(), Kind::ALL.len());
|
||||
for path in &written {
|
||||
assert!(path.exists());
|
||||
let text = std::fs::read_to_string(path).unwrap();
|
||||
|
||||
+162
-2
@@ -88,6 +88,16 @@ pub enum Rule {
|
||||
/// An expectation of low discrimination on a higher-level item.
|
||||
ContradictoryDesign,
|
||||
|
||||
// --- the option pool ---
|
||||
/// Fewer usable distractors than a form shows.
|
||||
ThinOptionPool,
|
||||
/// An option that has never been administered and has not been retired.
|
||||
UnusedOption,
|
||||
/// An option retired without saying what it did.
|
||||
UnjustifiedRetirement,
|
||||
/// A distractor that has never been chosen, in any set it appeared in.
|
||||
NonfunctioningDistractor,
|
||||
|
||||
// --- evidence ---
|
||||
/// Statistics describe an older version of the item.
|
||||
StaleCalibration,
|
||||
@@ -104,7 +114,7 @@ impl Rule {
|
||||
///
|
||||
/// Used by `--list-rules`, and by the test that keeps this list in step with
|
||||
/// the enum.
|
||||
pub const ALL: [Rule; 26] = [
|
||||
pub const ALL: [Rule; 30] = [
|
||||
Rule::KeyIsLongest,
|
||||
Rule::UnevenOptionLength,
|
||||
Rule::WordRepeatCue,
|
||||
@@ -127,6 +137,10 @@ impl Rule {
|
||||
Rule::WeakFormatForLevel,
|
||||
Rule::ScoredBonusLevel,
|
||||
Rule::ContradictoryDesign,
|
||||
Rule::ThinOptionPool,
|
||||
Rule::UnusedOption,
|
||||
Rule::UnjustifiedRetirement,
|
||||
Rule::NonfunctioningDistractor,
|
||||
Rule::StaleCalibration,
|
||||
Rule::DifficultyMissed,
|
||||
Rule::DiscriminationMissed,
|
||||
@@ -152,6 +166,9 @@ impl Rule {
|
||||
// Statistics attached to text that has since changed are actively
|
||||
// misleading, which is worse than absent.
|
||||
R::StaleCalibration => Severity::High,
|
||||
// An item that cannot fill a form is an item `assemble` will put on
|
||||
// a paper short an option.
|
||||
R::ThinOptionPool => Severity::High,
|
||||
// An unanswerable question for a screen-reader user.
|
||||
R::AssetWithoutAltText => Severity::High,
|
||||
|
||||
@@ -178,6 +195,13 @@ impl Rule {
|
||||
| R::NoStudentFeedback
|
||||
| R::DifficultyMissed
|
||||
| R::DiscriminationMissed => Severity::Low,
|
||||
|
||||
// A distractor that draws nobody across several administrations is
|
||||
// evidence to act on, not a style note.
|
||||
R::NonfunctioningDistractor => Severity::Medium,
|
||||
// Both are tidiness: the item still works, but its pool is
|
||||
// carrying something nobody has accounted for.
|
||||
R::UnusedOption | R::UnjustifiedRetirement => Severity::Low,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +230,10 @@ impl Rule {
|
||||
Rule::WeakFormatForLevel => "complete-format-level",
|
||||
Rule::ScoredBonusLevel => "complete-bonus-policy",
|
||||
Rule::ContradictoryDesign => "complete-design-conflict",
|
||||
Rule::ThinOptionPool => "pool-thin",
|
||||
Rule::UnusedOption => "pool-unused",
|
||||
Rule::UnjustifiedRetirement => "pool-unjustified-retirement",
|
||||
Rule::NonfunctioningDistractor => "evidence-nonfunctioning",
|
||||
Rule::StaleCalibration => "evidence-stale",
|
||||
Rule::DifficultyMissed => "evidence-difficulty",
|
||||
Rule::DiscriminationMissed => "evidence-discrimination",
|
||||
@@ -238,9 +266,11 @@ impl Rule {
|
||||
| Rule::WeakFormatForLevel
|
||||
| Rule::ScoredBonusLevel
|
||||
| Rule::ContradictoryDesign => "completeness",
|
||||
Rule::ThinOptionPool | Rule::UnusedOption | Rule::UnjustifiedRetirement => "pool",
|
||||
Rule::StaleCalibration
|
||||
| Rule::DifficultyMissed
|
||||
| Rule::DiscriminationMissed
|
||||
| Rule::NonfunctioningDistractor
|
||||
| Rule::DuplicateStem => "evidence",
|
||||
}
|
||||
}
|
||||
@@ -270,6 +300,10 @@ impl Rule {
|
||||
Rule::WeakFormatForLevel,
|
||||
Rule::ScoredBonusLevel,
|
||||
Rule::ContradictoryDesign,
|
||||
Rule::ThinOptionPool,
|
||||
Rule::UnusedOption,
|
||||
Rule::UnjustifiedRetirement,
|
||||
Rule::NonfunctioningDistractor,
|
||||
Rule::StaleCalibration,
|
||||
Rule::DifficultyMissed,
|
||||
Rule::DiscriminationMissed,
|
||||
@@ -302,6 +336,12 @@ impl Rule {
|
||||
Rule::WeakFormatForLevel => "true/false at an analytic level",
|
||||
Rule::ScoredBonusLevel => "a level the policy reserves for bonus is scored",
|
||||
Rule::ContradictoryDesign => "low expected discrimination on a higher-level item",
|
||||
Rule::ThinOptionPool => "fewer usable distractors than a form shows",
|
||||
Rule::UnusedOption => "an option has never been administered and is not retired",
|
||||
Rule::UnjustifiedRetirement => "an option was retired without saying what it did",
|
||||
Rule::NonfunctioningDistractor => {
|
||||
"a distractor has never been chosen in any set it appeared in"
|
||||
}
|
||||
Rule::StaleCalibration => "statistics describe an older version of the item",
|
||||
Rule::DifficultyMissed => "observed difficulty was far from predicted",
|
||||
Rule::DiscriminationMissed => "observed discrimination contradicted the prediction",
|
||||
@@ -749,6 +789,87 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
|
||||
}
|
||||
}
|
||||
|
||||
// --- the option pool
|
||||
// These only make sense once options are a pool, and the pool is where an
|
||||
// item's spare parts sit. A bank that never draws from it will not trip any
|
||||
// of them.
|
||||
if it.format.has_options() {
|
||||
let (keys, distractors) = it.pool();
|
||||
let wanted = course.policy.options_per_item.saturating_sub(1);
|
||||
if distractors.len() < wanted {
|
||||
push(
|
||||
Rule::ThinOptionPool,
|
||||
Severity::High,
|
||||
format!(
|
||||
"has {} usable distractor(s) but a form shows {}, so `assemble` will put \
|
||||
this on a paper an option short",
|
||||
distractors.len(),
|
||||
course.policy.options_per_item
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for option in &it.options {
|
||||
match &option.retired {
|
||||
Some(retirement) => {
|
||||
if retirement.reason.trim().len() < 12 {
|
||||
push(
|
||||
Rule::UnjustifiedRetirement,
|
||||
Severity::Low,
|
||||
format!(
|
||||
"option `{}` is retired with no real reason. The reason is the \
|
||||
finding — what it drew, or failed to draw — and it is the only \
|
||||
part of a retirement worth anything in two years",
|
||||
option.id
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// An option nobody has been shown is a draft, and a draft
|
||||
// sitting in an approved item's pool will eventually be
|
||||
// drawn onto a paper without ever having been reviewed
|
||||
// against data.
|
||||
if let Some(history) = it
|
||||
.calibration
|
||||
.as_ref()
|
||||
.filter(|c| !c.options.is_empty())
|
||||
.and_then(|c| c.options.get(&option.id))
|
||||
{
|
||||
if history.never_chosen && history.appearances > 1 {
|
||||
push(
|
||||
Rule::NonfunctioningDistractor,
|
||||
Severity::Medium,
|
||||
format!(
|
||||
"option `{}` has appeared in {} option set(s) across {} \
|
||||
examinees and has never been chosen. One administration \
|
||||
would not show this; several do.",
|
||||
option.id, history.appearances, history.n_examinees
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if it
|
||||
.calibration
|
||||
.as_ref()
|
||||
.is_some_and(|c| !c.options.is_empty())
|
||||
{
|
||||
push(
|
||||
Rule::UnusedOption,
|
||||
Severity::Low,
|
||||
format!(
|
||||
"option `{}` has never been administered. Either it is waiting \
|
||||
its turn, or it was drafted and forgotten — retire it and say \
|
||||
which.",
|
||||
option.id
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = keys;
|
||||
}
|
||||
|
||||
// --- evidence
|
||||
if !it.calibration_is_current() {
|
||||
push(
|
||||
@@ -1196,7 +1317,7 @@ mod tests {
|
||||
fn entry(yaml: &str) -> Entry {
|
||||
let item: Item = serde_yaml_ng::from_str(yaml).expect("item parses");
|
||||
Entry {
|
||||
uid: format!("b::{}", item.id),
|
||||
uid: item.id.clone(),
|
||||
bank: "b".into(),
|
||||
path: PathBuf::from("b.yaml"),
|
||||
index: 0,
|
||||
@@ -1215,6 +1336,45 @@ mod tests {
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pool_too_thin_to_fill_a_form_is_flagged() {
|
||||
let c = codes(
|
||||
r#"
|
||||
id: q-a-001
|
||||
status: draft
|
||||
level: 2
|
||||
stem: Which mechanism best explains the sigmoidal binding curve?
|
||||
options:
|
||||
- { id: o-shift, text: Ligand binding shifts the tetramer to a higher-affinity state, correct: true }
|
||||
- { id: o-fixed, text: Each subunit binds with the same fixed affinity throughout }
|
||||
"#,
|
||||
);
|
||||
// Policy shows four options and the pool can supply two, so `assemble`
|
||||
// would put this on a paper two short.
|
||||
assert!(c.contains(&"pool-thin"), "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retirement_with_no_finding_is_flagged() {
|
||||
let c = codes(
|
||||
r#"
|
||||
id: q-a-001
|
||||
status: draft
|
||||
level: 2
|
||||
stem: Which mechanism best explains the sigmoidal binding curve?
|
||||
options:
|
||||
- { id: o-shift, text: Ligand binding shifts the tetramer to a higher-affinity state, correct: true }
|
||||
- { id: o-fixed, text: Each subunit binds with the same fixed affinity throughout }
|
||||
- { id: o-consumed, text: "Ligand is consumed as it binds, depleting the available pool" }
|
||||
- { id: o-oxidation, text: The heme iron changes oxidation state upon binding }
|
||||
- { id: o-cooperative, text: Subunits bind independently of one another, retired: { 'on': 2026-09-20, reason: bad } }
|
||||
"#,
|
||||
);
|
||||
assert!(c.contains(&"pool-unjustified-retirement"), "{c:?}");
|
||||
// Four live distractors is enough for a four-option form.
|
||||
assert!(!c.contains(&"pool-thin"), "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_item_passes() {
|
||||
let c = codes(
|
||||
|
||||
+253
-18
@@ -29,12 +29,13 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::assessment::{Assessment, AssessmentFile, Blueprint, Form, Kind, Placement, Platform};
|
||||
use crate::catalog::Catalog;
|
||||
use crate::course::SCHEMA_VERSION;
|
||||
use crate::course::{CourseFile, SCHEMA_VERSION};
|
||||
use crate::date::Date;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::history::History;
|
||||
use crate::item::{Choice, Item};
|
||||
use crate::rng::Rng;
|
||||
use crate::taxonomy::Level;
|
||||
use crate::taxonomy::{Format, Level};
|
||||
|
||||
/// The result of a draw.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -106,10 +107,20 @@ pub fn select(
|
||||
// to become unsatisfiable once the level quotas are full.
|
||||
for (objective, needed) in &blueprint.objective_minimums {
|
||||
let mut have = 0;
|
||||
// A requirement written against an objective is satisfied by items on
|
||||
// any of its targets, which is the only way a coverage requirement stays
|
||||
// writable: a blueprint that had to name each target separately would be
|
||||
// as long as the registry, and would need editing every time an
|
||||
// objective gained one.
|
||||
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
|
||||
.learning_targets
|
||||
.iter()
|
||||
.any(|t| t == objective || catalog.course.objective_for(t) == objective)
|
||||
})
|
||||
.filter(|e| !e.item.bonus)
|
||||
.collect();
|
||||
rank(&mut candidates, history, seed, "objective");
|
||||
@@ -456,19 +467,29 @@ pub fn to_record(
|
||||
.chain(selection.bonus.iter().map(|u| (u, true))),
|
||||
) {
|
||||
let e = catalog.require(uid)?;
|
||||
let (key, distractors) = draw_options(
|
||||
&e.item,
|
||||
catalog.course.policy.options_per_item,
|
||||
blueprint.seed.unwrap_or(0),
|
||||
uid,
|
||||
);
|
||||
items.push(Placement {
|
||||
number,
|
||||
item: uid.clone(),
|
||||
version: Some(e.item.version),
|
||||
version: None,
|
||||
stem_digest: Some(e.item.stem_digest()),
|
||||
variant: Some(e.item.variant_digest(&key, &distractors)),
|
||||
fingerprint: Some(e.item.fingerprint()),
|
||||
points: Some(e.item.points(default_points)),
|
||||
bonus: is_bonus || e.item.bonus,
|
||||
key: e.item.key_letters(),
|
||||
distractors,
|
||||
key,
|
||||
level: Some(e.item.level),
|
||||
learning_objectives: e.item.learning_objectives.clone(),
|
||||
learning_targets: e.item.learning_targets.clone(),
|
||||
credit_overrides: BTreeMap::new(),
|
||||
dropped: false,
|
||||
dropped_as: None,
|
||||
dropped_before_printing: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -559,6 +580,85 @@ pub fn layout(record: &AssessmentFile, form: &Form) -> Vec<Placement> {
|
||||
scored.into_iter().chain(bonus).collect()
|
||||
}
|
||||
|
||||
/// Draws the key and the distractors one placement administers.
|
||||
///
|
||||
/// Resolved here, at assembly, and written into the record as explicit lists.
|
||||
/// Nothing downstream samples: an export that drew its own options would print
|
||||
/// a different paper every time the bank was touched.
|
||||
///
|
||||
/// The draw is seeded on the blueprint and the item, so re-running `assemble`
|
||||
/// with the same seed produces the same paper, and two items in one assessment
|
||||
/// draw independently.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `item` - the item, whose options are a pool.
|
||||
/// * `per_item` - how many options a form shows, from course policy.
|
||||
/// * `seed` - the blueprint seed.
|
||||
/// * `uid` - the item id, salting the draw.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The keyed ids and the distractor ids, each sorted, naming options of `item`.
|
||||
/// Both empty for an item with no options, which is an open response.
|
||||
pub fn draw_options(
|
||||
item: &Item,
|
||||
per_item: usize,
|
||||
seed: u64,
|
||||
uid: &str,
|
||||
) -> (Vec<String>, Vec<String>) {
|
||||
let (keys, distractors) = item.pool();
|
||||
if keys.is_empty() && distractors.is_empty() {
|
||||
return (Vec::new(), Vec::new());
|
||||
}
|
||||
|
||||
// Multiple response keys every correct option; anything else keys one, and
|
||||
// when the pool offers several defensible keys the draw picks one so that
|
||||
// the record says which.
|
||||
let wanted_keys = match item.format {
|
||||
Format::MultipleResponse => keys.len(),
|
||||
_ => 1.min(keys.len()),
|
||||
};
|
||||
let mut rng = Rng::from_label(&format!("{seed}/{uid}/options"));
|
||||
|
||||
let mut key_ids = pick(&keys, wanted_keys, &mut rng);
|
||||
key_ids.sort();
|
||||
|
||||
// A pool with fewer usable distractors than the policy asks for is a
|
||||
// finding, not a failure: the form comes out short and `lint` says so,
|
||||
// rather than `assemble` refusing to build the assessment at all.
|
||||
let wanted = per_item.saturating_sub(key_ids.len());
|
||||
let mut distractor_ids = pick(&distractors, wanted.min(distractors.len()), &mut rng);
|
||||
distractor_ids.sort();
|
||||
|
||||
(key_ids, distractor_ids)
|
||||
}
|
||||
|
||||
/// Takes `n` options, preferring the ones that were designed rather than merely
|
||||
/// written.
|
||||
///
|
||||
/// A distractor carrying a misconception and an error type is one you thought
|
||||
/// about; one carrying neither is filler. When the pool is larger than the form,
|
||||
/// the thought-about ones go on the paper. The shuffle comes first so that
|
||||
/// options of equal standing are drawn by seed rather than by declaration
|
||||
/// order.
|
||||
fn pick(options: &[&Choice], n: usize, rng: &mut Rng) -> Vec<String> {
|
||||
if n >= options.len() {
|
||||
return options.iter().map(|o| o.id.clone()).collect();
|
||||
}
|
||||
let mut order: Vec<usize> = (0..options.len()).collect();
|
||||
rng.shuffle(&mut order);
|
||||
order.sort_by_key(|&i| {
|
||||
let o = options[i];
|
||||
u8::from(o.misconception.is_none()) + u8::from(o.error_type.is_none())
|
||||
});
|
||||
order
|
||||
.into_iter()
|
||||
.take(n)
|
||||
.map(|i| options[i].id.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The option order for one item on one form.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -585,11 +685,14 @@ pub fn option_order(form: &Form, uid: &str, n: usize) -> Vec<usize> {
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `record` - the assessment record.
|
||||
/// * `course` - the course, for the objective each tagged target belongs to. An
|
||||
/// objective minimum is satisfied by items on any of that objective's
|
||||
/// targets, the same way [`select`] fills it.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// One message per discrepancy, empty when the form matches the design.
|
||||
pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
|
||||
pub fn check_blueprint(record: &AssessmentFile, course: &CourseFile) -> Vec<String> {
|
||||
let Some(bp) = &record.blueprint else {
|
||||
return vec!["the record carries no blueprint to check against".into()];
|
||||
};
|
||||
@@ -609,7 +712,11 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
|
||||
let got = record
|
||||
.items
|
||||
.iter()
|
||||
.filter(|p| p.learning_objectives.iter().any(|o| o == objective))
|
||||
.filter(|p| {
|
||||
p.learning_targets
|
||||
.iter()
|
||||
.any(|t| t == objective || course.objective_for(t) == objective)
|
||||
})
|
||||
.count();
|
||||
if got < *needed {
|
||||
out.push(format!(
|
||||
@@ -620,7 +727,7 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The set of objectives an assessment covers.
|
||||
/// The set of learning targets an assessment covers, as tagged.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
@@ -628,12 +735,36 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The objective ids, deduplicated.
|
||||
pub fn covered_objectives(record: &AssessmentFile) -> BTreeSet<String> {
|
||||
/// The target ids, deduplicated.
|
||||
pub fn covered_targets(record: &AssessmentFile) -> BTreeSet<String> {
|
||||
record
|
||||
.items
|
||||
.iter()
|
||||
.flat_map(|p| p.learning_objectives.iter().cloned())
|
||||
.flat_map(|p| p.learning_targets.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The objectives an assessment covers, through the targets it measured.
|
||||
///
|
||||
/// The list a coverage claim should be made from: "this exam covered eleven of
|
||||
/// the course's thirty-two objectives" is a sentence about the blueprint, while
|
||||
/// the same count over targets is a sentence about how finely the course happens
|
||||
/// to be subdivided.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `record` - the assessment record.
|
||||
/// * `course` - the course, for the objective each tagged target belongs to.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The objective ids, deduplicated.
|
||||
pub fn covered_objectives(record: &AssessmentFile, course: &CourseFile) -> BTreeSet<String> {
|
||||
record
|
||||
.items
|
||||
.iter()
|
||||
.flat_map(|p| p.learning_targets.iter())
|
||||
.map(|t| course.objective_for(t).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -650,6 +781,68 @@ mod tests {
|
||||
assert_eq!(form_label(27), "AB");
|
||||
}
|
||||
|
||||
/// An item whose options are given as YAML, so the test needs no literal.
|
||||
fn pool_item(options: &str) -> Item {
|
||||
let src = format!(
|
||||
r#"id: q-x
|
||||
status: approved
|
||||
level: 1
|
||||
cognitive_process: recall
|
||||
stem: Which line holds the quality scores?
|
||||
learning_targets: [t-x]
|
||||
sources: [{{ lecture: L1 }}]
|
||||
options:
|
||||
{options}"#
|
||||
);
|
||||
serde_yaml_ng::from_str(&src).expect("item parses")
|
||||
}
|
||||
|
||||
const DESIGNED: &str = r#" - { id: o-key, text: right, correct: true }
|
||||
- { id: o-designed-a, text: a, misconception: mistakes the separator, error_type: recall_confusion }
|
||||
- { id: o-designed-b, text: b, misconception: confuses the two, error_type: recall_confusion }
|
||||
- { id: o-filler-a, text: c }
|
||||
- { id: o-filler-b, text: d }
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn a_draw_prefers_designed_distractors_and_is_reproducible() {
|
||||
let item = pool_item(DESIGNED);
|
||||
|
||||
let (key, distractors) = draw_options(&item, 3, 1103, "q-x");
|
||||
assert_eq!(key, vec!["o-key".to_string()]);
|
||||
assert_eq!(distractors.len(), 2);
|
||||
// Thought-about distractors go on the paper before filler does.
|
||||
assert!(
|
||||
distractors.iter().all(|d| d.starts_with("o-designed")),
|
||||
"{distractors:?}"
|
||||
);
|
||||
|
||||
// Same seed, same paper.
|
||||
assert_eq!(draw_options(&item, 3, 1103, "q-x"), (key, distractors));
|
||||
|
||||
// A retired option is not drawn, and the form comes out of the rest.
|
||||
let retired = pool_item(&DESIGNED.replace(
|
||||
"{ id: o-designed-a, text: a,",
|
||||
"{ id: o-designed-a, text: a, retired: { 'on': 2026-09-20, reason: nonfunctioning },",
|
||||
));
|
||||
let (_, after) = draw_options(&retired, 3, 1103, "q-x");
|
||||
assert!(!after.iter().any(|d| d == "o-designed-a"), "{after:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thin_pool_comes_out_short_rather_than_refusing_to_build() {
|
||||
let item = pool_item(
|
||||
" - { id: o-key, text: right, correct: true }\n - { id: o-one, text: wrong }\n",
|
||||
);
|
||||
let (key, distractors) = draw_options(&item, 4, 7, "q-y");
|
||||
assert_eq!(key.len(), 1);
|
||||
assert_eq!(
|
||||
distractors.len(),
|
||||
1,
|
||||
"one usable distractor, so one is drawn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_order_is_a_reproducible_permutation() {
|
||||
let form = Form {
|
||||
@@ -690,12 +883,14 @@ 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: 1, item: "b::q-1", level: 1, learning_targets: [lo-key] }
|
||||
- { number: 2, item: "b::q-2", level: 1 }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let issues = check_blueprint(&record);
|
||||
let course: CourseFile =
|
||||
serde_yaml_ng::from_str("course: { code: C, title: T, term: M }").unwrap();
|
||||
let issues = check_blueprint(&record, &course);
|
||||
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.
|
||||
@@ -703,17 +898,57 @@ items:
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covered_objectives_deduplicates() {
|
||||
fn an_objective_minimum_is_met_by_items_on_its_targets() {
|
||||
// The blueprint names the objective a report will classify; the items
|
||||
// are tagged with the specific performances they measure.
|
||||
let record: AssessmentFile = serde_yaml_ng::from_str(
|
||||
r#"
|
||||
assessment: { id: x, title: X }
|
||||
blueprint:
|
||||
objective_minimums: { lo-binding: 2 }
|
||||
items:
|
||||
- { number: 1, item: "b::q-1", level: 1, learning_targets: [t-kd] }
|
||||
- { number: 2, item: "b::q-2", level: 3, learning_targets: [t-plot] }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let course: CourseFile = serde_yaml_ng::from_str(
|
||||
r#"
|
||||
course: { code: C, title: T, term: M }
|
||||
learning_objectives:
|
||||
lo-binding: { text: Quantify binding. }
|
||||
learning_targets:
|
||||
t-kd: { text: Write the expression., objective: lo-binding }
|
||||
t-plot: { text: Read a plot., objective: lo-binding }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let issues = check_blueprint(&record, &course);
|
||||
assert!(
|
||||
!issues.iter().any(|i| i.contains("lo-binding")),
|
||||
"two items on its targets satisfy it: {issues:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
covered_objectives(&record, &course)
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["lo-binding"],
|
||||
"coverage is claimed at the tier the blueprint is written in"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covered_targets_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] }
|
||||
- { number: 1, item: "b::q-1", learning_targets: [lo-a, lo-b] }
|
||||
- { number: 2, item: "b::q-2", learning_targets: [lo-a] }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let set = covered_objectives(&record);
|
||||
let set = covered_targets(&record);
|
||||
assert_eq!(set.len(), 2);
|
||||
assert!(set.contains("lo-a"));
|
||||
}
|
||||
|
||||
+184
-10
@@ -24,6 +24,7 @@ use coursebank::assessment::{Kind as AssessmentKind, Platform};
|
||||
use coursebank::catalog::Severity;
|
||||
use coursebank::item::IrtModel;
|
||||
use coursebank::lecture::Style as PageStyle;
|
||||
use coursebank::references;
|
||||
use coursebank::store;
|
||||
|
||||
/// Manage course item banks, assessments, and the analysis that comes back.
|
||||
@@ -48,6 +49,15 @@ pub(crate) struct Cli {
|
||||
pub(crate) enum Command {
|
||||
/// Create a new course directory.
|
||||
Init(InitArgs),
|
||||
/// Inspect the course file.
|
||||
#[command(subcommand)]
|
||||
Course(CourseCommand),
|
||||
/// One-time conversions from an older layout.
|
||||
#[command(subcommand)]
|
||||
Migrate(MigrateCommand),
|
||||
/// Work with the bibliography.
|
||||
#[command(subcommand)]
|
||||
References(ReferencesCommand),
|
||||
/// Write JSON Schemas so your editor can validate the YAML as you type.
|
||||
Schema,
|
||||
/// Check every file for problems that must be fixed.
|
||||
@@ -56,7 +66,7 @@ pub(crate) enum Command {
|
||||
Lint(LintArgs),
|
||||
/// Summarize the item pool and objective coverage.
|
||||
Catalog(CatalogArgs),
|
||||
/// Render a lecture's reading list, or check what backs each objective.
|
||||
/// Render a lecture's reading list, or check what backs each target.
|
||||
#[command(subcommand)]
|
||||
Lecture(LectureCommand),
|
||||
/// Work with item banks.
|
||||
@@ -93,6 +103,158 @@ pub(crate) enum Command {
|
||||
Data,
|
||||
}
|
||||
|
||||
/// `course`: the course file itself, which may be one file or many.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum CourseCommand {
|
||||
/// List the files the course is assembled from.
|
||||
Files,
|
||||
/// Print the merged course, or write it to a file.
|
||||
///
|
||||
/// Nothing reads what this writes. It exists so you can see what the
|
||||
/// fragments add up to, and diff two revisions of a course that no longer
|
||||
/// lives in one file.
|
||||
Build {
|
||||
/// Output path; prints to stdout when omitted.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Say which file defines an id.
|
||||
Where {
|
||||
/// A unit, lecture, objective, target, reference, or stimulus id.
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// `migrate`: the one-time conversions, grouped so they are findable together.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum MigrateCommand {
|
||||
/// Split one course.yaml into references.yaml, lectures/, and objectives/.
|
||||
///
|
||||
/// The original is kept as course.yaml.bak, and the result is reassembled
|
||||
/// and compared against it before the command reports success.
|
||||
Split {
|
||||
/// Show what would be written, and write nothing.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
/// Drop the trailing counter from every item id.
|
||||
///
|
||||
/// A rename, not a normalization: `q-x-001` and `q-x` are unrelated
|
||||
/// strings, so banks, records, seals, and the response store are rewritten
|
||||
/// in one pass or not at all. Two ids that would collide abort it.
|
||||
Counters {
|
||||
/// Show the renames, and write nothing.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
/// Replace the `order:` integers with ordered declarations.
|
||||
///
|
||||
/// Objective order comes from each lecture's `teaches` list, target order
|
||||
/// from a `targets:` list this writes onto each objective. Run
|
||||
/// `migrate split` first — without `teaches`, objective order has no
|
||||
/// source.
|
||||
Order {
|
||||
/// Show what would change, and write nothing.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
/// Turn citations written into `note:` fields into real fields.
|
||||
///
|
||||
/// Journal, volume, pages, and DOI parsed out of the prose, with whatever
|
||||
/// the note still says left in it. A field the entry already declares is
|
||||
/// never overwritten; a disagreement is reported instead.
|
||||
References {
|
||||
/// Show what would change, and write nothing.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
/// Fill in the stored `variant` column from the assessment records.
|
||||
///
|
||||
/// Nothing in the tool needs it — a variant is derived from the placement
|
||||
/// when a row has none. It is for pandas, DuckDB, and R, which see only
|
||||
/// what is in the column and will otherwise average two option sets of one
|
||||
/// stem into an item that never existed.
|
||||
Variants {
|
||||
/// Show what would change, and write nothing.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
/// Drop `version:` and `history:`, which 2.0 ignores.
|
||||
///
|
||||
/// A stem's text is its identity: reword it and it is a new item with a new
|
||||
/// id and `supersedes:` pointing back. `validate` enforces that against
|
||||
/// every seal, so what a version number used to hint at is now checked.
|
||||
Stems {
|
||||
/// Show what would change, and write nothing.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
/// Rewrite option letters as names derived from the option text.
|
||||
///
|
||||
/// A letter is a position, and a position in a field that pooled
|
||||
/// statistics and `credit_overrides` join on is a bug waiting for someone
|
||||
/// to reorder a YAML block. Run with --dry-run first: the names land in
|
||||
/// the response store, so they are as permanent as an item id.
|
||||
Options {
|
||||
/// Show the derived names, and write nothing.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
/// Rewrite pre-2.0 `bank::item` ids as the item ids they name.
|
||||
///
|
||||
/// Touches assessment records, seals, and the response store. Everything
|
||||
/// keeps working unmigrated — an old id still resolves — but a store
|
||||
/// holding both forms groups one question into two for anything reading the
|
||||
/// Parquet without this tool.
|
||||
Ids {
|
||||
/// Show what would change, and write nothing.
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// `references`: the bibliography, and the formats other tools read it in.
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum ReferencesCommand {
|
||||
/// List every work, with the link a reading list would use.
|
||||
///
|
||||
/// The column that matters is the last one: a work with no link is one a
|
||||
/// student cannot reach from a report, which for a manuscript usually means
|
||||
/// its DOI is missing.
|
||||
List,
|
||||
/// Write the bibliography in a citation format.
|
||||
Export {
|
||||
/// Which format to write.
|
||||
#[arg(long, value_enum, default_value = "hayagriva")]
|
||||
format: ReferenceFormat,
|
||||
/// Output path; prints to stdout when omitted.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
/// The citation formats `references export` can write.
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
pub(crate) enum ReferenceFormat {
|
||||
/// Hayagriva YAML, which Typst reads natively.
|
||||
Hayagriva,
|
||||
/// CSL-JSON, for Zotero, Pandoc, and CSL processors.
|
||||
CslJson,
|
||||
/// BibTeX.
|
||||
Bibtex,
|
||||
}
|
||||
|
||||
impl ReferenceFormat {
|
||||
/// The library-side format.
|
||||
pub(crate) fn as_format(self) -> references::Format {
|
||||
match self {
|
||||
ReferenceFormat::Hayagriva => references::Format::Hayagriva,
|
||||
ReferenceFormat::CslJson => references::Format::CslJson,
|
||||
ReferenceFormat::Bibtex => references::Format::Bibtex,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct InitArgs {
|
||||
/// Course code, e.g. "BIOSC 1540".
|
||||
@@ -134,7 +296,7 @@ pub(crate) enum LectureCommand {
|
||||
/// Write the readings block for one lecture.
|
||||
///
|
||||
/// The course file is the source of truth for what a lecture assigns and why,
|
||||
/// so the list on the website is generated from it. Objective numbers are
|
||||
/// so the list on the website is generated from it. Target numbers are
|
||||
/// positional and are resolved here rather than authored.
|
||||
Readings {
|
||||
/// Lecture id, e.g. L1.1.
|
||||
@@ -146,10 +308,13 @@ pub(crate) enum LectureCommand {
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Write the objectives block for one lecture, grouped by level.
|
||||
/// Write the learning objectives block for one lecture, with each
|
||||
/// objective's learning targets enumerated beneath it.
|
||||
///
|
||||
/// The numbering comes from the same place as the `_(LO 4, 7)_` lists in
|
||||
/// `readings`, so generating one and hand-writing the other is what this
|
||||
/// One block rather than two: the objective and its targets are the same
|
||||
/// claim at two grains, and separate sections would print every target
|
||||
/// twice. The numbering comes from the same place as the `_(T 4, 7)_` lists
|
||||
/// in `readings`, so generating one and hand-writing the other is what this
|
||||
/// exists to prevent.
|
||||
Objectives {
|
||||
/// Lecture id, e.g. L1.1.
|
||||
@@ -161,9 +326,9 @@ pub(crate) enum LectureCommand {
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Show the readings behind each objective, and which objectives have none.
|
||||
/// Show the readings behind each learning target, and which have none.
|
||||
Coverage {
|
||||
/// Only this lecture's objectives.
|
||||
/// Only this lecture's targets.
|
||||
#[arg(long)]
|
||||
lecture: Option<String>,
|
||||
},
|
||||
@@ -207,7 +372,7 @@ impl SeverityArg {
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct CatalogArgs {
|
||||
/// Show per-objective coverage and the gaps in it.
|
||||
/// Show coverage per objective and per target, and the gaps in it.
|
||||
#[arg(long)]
|
||||
pub(crate) coverage: bool,
|
||||
/// Show topic counts.
|
||||
@@ -296,7 +461,8 @@ pub(crate) struct AssembleArgs {
|
||||
/// Bonus items per level, same syntax.
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
pub(crate) bonus: Vec<String>,
|
||||
/// Minimum items per objective, e.g. --require lo-kinetics=2.
|
||||
/// Minimum items per objective, e.g. --require lo-kinetics=2. Satisfied by
|
||||
/// items on any of that objective's targets.
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
pub(crate) require: Vec<String>,
|
||||
/// Restrict the draw to these lectures.
|
||||
@@ -589,6 +755,14 @@ pub(crate) enum AnalyzeCommand {
|
||||
/// Pool every stored administration of this assessment.
|
||||
#[arg(long)]
|
||||
pooled: bool,
|
||||
/// Also write the administration record under analysis/.
|
||||
///
|
||||
/// Two CSVs, one row per question and one per question-and-option,
|
||||
/// written once and never rewritten. Cohort aggregates only, so unlike
|
||||
/// the response data they are meant to be committed — which is what
|
||||
/// keeps the history when `data/` rotates.
|
||||
#[arg(long = "record")]
|
||||
record: bool,
|
||||
},
|
||||
/// Fit an IRT model.
|
||||
Irt {
|
||||
@@ -799,4 +973,4 @@ mod tests {
|
||||
fn the_cli_rejects_an_unknown_subcommand() {
|
||||
assert!(Cli::try_parse_from(["coursebank", "frobnicate"]).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -8,8 +8,8 @@
|
||||
//! calls the matching handler. The handlers themselves live in submodules that
|
||||
//! follow the workflow described in the crate documentation:
|
||||
//!
|
||||
//! - [`project`] — set up and check a course: `init`, `schema`, `validate`,
|
||||
//! `lint`, `catalog`.
|
||||
//! - [`project`] — set up and check a course: `init`, `course`, `schema`,
|
||||
//! `validate`, `lint`, `catalog`.
|
||||
//! - [`lectures`] — render a lecture's reading list and check what backs each
|
||||
//! objective: `lecture`.
|
||||
//! - [`banks`] — manage items and build assessments: `bank`, `assessment`,
|
||||
@@ -55,6 +55,9 @@ pub(crate) enum Outcome {
|
||||
pub(crate) fn run(cli: &Cli) -> Result<Outcome> {
|
||||
match &cli.command {
|
||||
Command::Init(args) => project::init(cli, args),
|
||||
Command::Course(sub) => project::course(cli, sub),
|
||||
Command::References(sub) => project::references(cli, sub),
|
||||
Command::Migrate(sub) => project::migrate(cli, sub),
|
||||
Command::Schema => project::schema(cli),
|
||||
Command::Validate => project::validate(cli),
|
||||
Command::Lint(args) => project::lint(cli, args),
|
||||
|
||||
@@ -294,12 +294,46 @@ pub(crate) fn analyze(cli: &Cli, sub: &AnalyzeCommand) -> Result<Outcome> {
|
||||
let store = Store::open(catalog.layout.data())?;
|
||||
|
||||
match sub {
|
||||
AnalyzeCommand::Items { id, pooled } => {
|
||||
AnalyzeCommand::Items {
|
||||
id,
|
||||
pooled,
|
||||
record: write_record,
|
||||
} => {
|
||||
let record = load_record(&catalog, id)?;
|
||||
let set = responses_for(&store, &catalog, &record, *pooled)?;
|
||||
let analysis =
|
||||
classical::analyze(&set, &Thresholds::default(), Some(&record), Some(&catalog));
|
||||
|
||||
if *write_record {
|
||||
// The full administration id, not the assessment id: `e1` is
|
||||
// given again next year, and a record named after it would
|
||||
// collide with this cohort's — which `write_csv` would report
|
||||
// as "already recorded" when it is a different exam entirely.
|
||||
let administration = coursebank::responses::administration_id(
|
||||
&catalog.course.course.code,
|
||||
record
|
||||
.assessment
|
||||
.term
|
||||
.as_deref()
|
||||
.unwrap_or(&catalog.course.course.term),
|
||||
&record.assessment.id,
|
||||
);
|
||||
let written = calibrate::record_measurements(
|
||||
&catalog.layout,
|
||||
&administration,
|
||||
&analysis,
|
||||
&catalog,
|
||||
Some(&record),
|
||||
)?;
|
||||
for path in &written {
|
||||
println!("wrote {}", path.display());
|
||||
}
|
||||
println!(
|
||||
"\nCohort aggregates only, so these are committed. Review with\n git diff \
|
||||
analysis/\n"
|
||||
);
|
||||
}
|
||||
|
||||
for w in &analysis.warnings {
|
||||
println!("! {w}\n");
|
||||
}
|
||||
@@ -426,7 +460,7 @@ pub(crate) fn analyze(cli: &Cli, sub: &AnalyzeCommand) -> Result<Outcome> {
|
||||
println!(
|
||||
" {:>4.0}% {}",
|
||||
rate * 100.0,
|
||||
catalog.course.objective_text(objective)
|
||||
catalog.course.text_for(objective)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -463,17 +497,20 @@ pub(crate) fn calibrate(cli: &Cli, args: &CalibrateArgs) -> Result<Outcome> {
|
||||
}
|
||||
if !args.apply {
|
||||
println!(
|
||||
"Nothing written. Re-run with --apply to write these {} change(s) into the bank \
|
||||
files, then review the git diff.",
|
||||
"Nothing written. Re-run with --apply to write these {} change(s) into \
|
||||
analysis/calibration.yaml, then review the git diff.",
|
||||
plan.changes.len()
|
||||
);
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
|
||||
for path in calibrate::apply(&plan)? {
|
||||
println!("updated {}", path.display());
|
||||
}
|
||||
println!("\nReview the diff before committing: git diff banks/");
|
||||
let path = calibrate::apply(&catalog.layout, &plan)?;
|
||||
println!("updated {}", path.display());
|
||||
println!(
|
||||
"\nReview the diff before committing: git diff analysis/\n\nThe banks are untouched. \
|
||||
Statistics are cohort aggregates with no student in\n them, which is why analysis/ is \
|
||||
committed and data/ is not."
|
||||
);
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
@@ -782,4 +819,4 @@ fn warn_about_drift(
|
||||
eprintln!(" {}", finding.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ pub(crate) fn assemble(cli: &Cli, args: &AssembleArgs) -> Result<Outcome> {
|
||||
|
||||
print_record(&catalog, &record);
|
||||
|
||||
let drift = select::check_blueprint(&record);
|
||||
let drift = select::check_blueprint(&record, &catalog.course);
|
||||
if !drift.is_empty() {
|
||||
println!("\nBlueprint not fully satisfied:");
|
||||
for d in &drift {
|
||||
|
||||
+13
-11
@@ -2,7 +2,7 @@
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! Rendering lecture pages, and checking what backs each objective.
|
||||
//! Rendering lecture pages, and checking what backs each learning target.
|
||||
//!
|
||||
//! Both handlers here read the course file and nothing else, so neither needs a
|
||||
//! bank or a single response. That is deliberate: a reading list is useful in week
|
||||
@@ -45,19 +45,21 @@ fn emit(markdown: String, out: Option<&std::path::Path>) -> Result<Outcome> {
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// Prints the readings behind each objective.
|
||||
/// Prints the readings behind each learning target.
|
||||
///
|
||||
/// Returns [`Outcome::Findings`] when an assessed objective has no reading, since
|
||||
/// that is the case where a student report can name what was missed but not where
|
||||
/// to go and read about it.
|
||||
/// Targets rather than objectives, because that is the tier a reading is cited
|
||||
/// against: a section of a book backs a specific performance. Returns
|
||||
/// [`Outcome::Findings`] when an assessed target has no reading, since that is
|
||||
/// the case where a student report can name what was missed but not where to go
|
||||
/// and read about it.
|
||||
fn coverage(course: &CourseFile, lecture: Option<&str>, quiet: bool) -> Result<Outcome> {
|
||||
let ids: Vec<String> = match lecture {
|
||||
Some(l) => course
|
||||
.lecture_objectives(l)
|
||||
.lecture_targets(l)
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
None => course.objectives_in_order(),
|
||||
None => course.targets_in_order(),
|
||||
};
|
||||
|
||||
for id in &ids {
|
||||
@@ -83,21 +85,21 @@ fn coverage(course: &CourseFile, lecture: Option<&str>, quiet: bool) -> Result<O
|
||||
if let Some(focus) = &reading.focus {
|
||||
println!(
|
||||
" {}",
|
||||
course.expand_objective_refs(focus, |o| { course.objective_text(o) })
|
||||
course.expand_objective_refs(focus, |id| { course.text_for(id) })
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let gaps = course.objectives_without_readings();
|
||||
let gaps = course.targets_without_readings();
|
||||
if gaps.is_empty() {
|
||||
if !quiet {
|
||||
println!("\nevery assessed objective has a reading behind it");
|
||||
println!("\nevery assessed target has a reading behind it");
|
||||
}
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
println!(
|
||||
"\n{} assessed objective(s) with no reading, so a student report cannot say \
|
||||
"\n{} assessed target(s) with no reading, so a student report cannot say \
|
||||
where to go back to:",
|
||||
gaps.len()
|
||||
);
|
||||
|
||||
+462
-13
@@ -5,9 +5,10 @@
|
||||
//! Setting up a course and checking it stays well-formed.
|
||||
//!
|
||||
//! These are the commands you reach for before and around authoring: create the
|
||||
//! directory (`init`), write editor schemas (`schema`), and run the two kinds of
|
||||
//! checking — [`validate`] for problems that must be fixed and [`lint`] for
|
||||
//! item-writing guidance. [`catalog`] summarizes the pool that results.
|
||||
//! directory (`init`), see and split the course file (`course`), export the
|
||||
//! bibliography (`references`), write editor schemas (`schema`), and run the two
|
||||
//! kinds of checking — [`validate`] for problems that must be fixed and [`lint`]
|
||||
//! for item-writing guidance. [`catalog`] summarizes the pool that results.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
@@ -15,24 +16,34 @@ use std::path::Path;
|
||||
|
||||
use coursebank::assessment::AssessmentFile;
|
||||
use coursebank::bank::BankFile;
|
||||
use coursebank::course::fragment::{self, Section};
|
||||
use coursebank::course::{COURSE_FILE, CourseFile};
|
||||
use coursebank::error::{Error, Result};
|
||||
use coursebank::jsonschema;
|
||||
use coursebank::layout::Layout;
|
||||
use coursebank::lint::{self, Rule};
|
||||
use coursebank::taxonomy::Level;
|
||||
use coursebank::migrate;
|
||||
use coursebank::references;
|
||||
use coursebank::taxonomy::{Level, Tier};
|
||||
use coursebank::yaml;
|
||||
|
||||
use crate::cli::{CatalogArgs, Cli, InitArgs, LintArgs};
|
||||
use crate::cli::{
|
||||
CatalogArgs, Cli, CourseCommand, InitArgs, LintArgs, MigrateCommand, ReferencesCommand,
|
||||
};
|
||||
use crate::commands::Outcome;
|
||||
use crate::helpers::{load, truncate};
|
||||
|
||||
/// The `.gitignore` written by `init`.
|
||||
pub(crate) const GITIGNORE: &str = "\
|
||||
# Generated output: exports, rendered exams, reports.
|
||||
# Generated output: exports, rendered exams, reports. A student report carries
|
||||
# names, so it belongs here rather than in the repository.
|
||||
build/
|
||||
reports/
|
||||
|
||||
# Response data: every row carries a student. The statistics derived from it are
|
||||
# cohort aggregates and live in analysis/, which is committed on purpose.
|
||||
data/
|
||||
|
||||
# Typst and PDF artifacts.
|
||||
*.pdf
|
||||
|
||||
@@ -77,13 +88,430 @@ pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result<Outcome> {
|
||||
|
||||
write_gitignore(&cli.course.join(".gitignore"))?;
|
||||
println!(
|
||||
"\nNext: edit {} to add your learning objectives and lectures, then\n \
|
||||
coursebank bank new unit-1 --title \"Unit 1\"\n coursebank validate",
|
||||
COURSE_FILE
|
||||
"\nNext: edit {COURSE_FILE} to add your learning objectives, their targets, and\n \
|
||||
your lectures, then\n coursebank bank new unit-1 --title \"Unit 1\"\n \
|
||||
coursebank validate\n\nOnce {COURSE_FILE} is more than you want to scroll, \
|
||||
`coursebank migrate split`\n moves each lecture and objective into its own file under \
|
||||
lectures/ and\n objectives/, and every command goes on reading the course as one."
|
||||
);
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// `course`: inspect the course file, or split it into fragments.
|
||||
pub(crate) fn course(cli: &Cli, sub: &CourseCommand) -> Result<Outcome> {
|
||||
match sub {
|
||||
CourseCommand::Files => course_files(cli),
|
||||
CourseCommand::Build { out } => course_build(cli, out.as_deref()),
|
||||
CourseCommand::Where { id } => course_where(cli, id),
|
||||
}
|
||||
}
|
||||
|
||||
/// `migrate`: the one-time layout conversions.
|
||||
pub(crate) fn migrate(cli: &Cli, sub: &MigrateCommand) -> Result<Outcome> {
|
||||
match sub {
|
||||
MigrateCommand::Split { dry_run } => course_split(cli, *dry_run),
|
||||
MigrateCommand::Ids { dry_run } => migrate_ids(cli, *dry_run),
|
||||
MigrateCommand::Options { dry_run } => migrate_options(cli, *dry_run),
|
||||
MigrateCommand::Stems { dry_run } => migrate_stems(cli, *dry_run),
|
||||
MigrateCommand::Variants { dry_run } => migrate_variants(cli, *dry_run),
|
||||
MigrateCommand::References { dry_run } => migrate_references(cli, *dry_run),
|
||||
MigrateCommand::Order { dry_run } => migrate_order(cli, *dry_run),
|
||||
MigrateCommand::Counters { dry_run } => migrate_counters(cli, *dry_run),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops the trailing counter from every item id.
|
||||
fn migrate_counters(cli: &Cli, dry_run: bool) -> Result<Outcome> {
|
||||
let (rename, touched) = migrate::counters(&cli.course, !dry_run)?;
|
||||
if rename.is_empty() {
|
||||
println!("nothing to do: no item id ends in a counter");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
|
||||
for (old, new) in &rename {
|
||||
println!(" {old} -> {new}");
|
||||
}
|
||||
println!();
|
||||
for (path, n) in &touched {
|
||||
println!(" {:<44} {n:>5} reference(s)", path.display());
|
||||
}
|
||||
if dry_run {
|
||||
println!("\nnothing written");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
|
||||
let sealed = touched
|
||||
.iter()
|
||||
.filter(|(p, _)| p.starts_with("seals"))
|
||||
.count();
|
||||
println!(
|
||||
"\nrenamed {} item(s) across {} file(s)",
|
||||
rename.len(),
|
||||
touched.len()
|
||||
);
|
||||
if sealed > 0 {
|
||||
println!(
|
||||
"\n{sealed} seal(s) were rewritten. The ids are inside the digest, so each one was \
|
||||
recomputed\n and the previous digest recorded under `superseded_digests`. A seal \
|
||||
that has been\n rewritten says so rather than looking untouched."
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"\nNext:\n coursebank validate\n coursebank seal verify\n coursebank analyze items \
|
||||
--all # the join key moved; check the data still lands"
|
||||
);
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// Replaces the order integers with ordered declarations.
|
||||
fn migrate_order(cli: &Cli, dry_run: bool) -> Result<Outcome> {
|
||||
let touched = migrate::order(&cli.course, !dry_run)?;
|
||||
if touched.is_empty() {
|
||||
println!("nothing to do: no `order:` left to derive");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
let total: usize = touched.iter().map(|(_, n)| n).sum();
|
||||
for (path, n) in &touched {
|
||||
println!(" {:<44} {n:>5} order(s) dropped", path.display());
|
||||
}
|
||||
if dry_run {
|
||||
println!("\nnothing written");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
println!(
|
||||
"\nrewrote {} file(s), {total} integer(s) gone\n\nNext:\n coursebank validate\n \
|
||||
coursebank lecture objectives L1.2 # check the order still reads right",
|
||||
touched.len()
|
||||
);
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// Takes the citations out of the notes and puts them in fields.
|
||||
fn migrate_references(cli: &Cli, dry_run: bool) -> Result<Outcome> {
|
||||
let (touched, notes) = migrate::references(&cli.course, !dry_run)?;
|
||||
|
||||
for (path, n) in &touched {
|
||||
println!(" {:<44} {n:>5} note(s) taken apart", path.display());
|
||||
}
|
||||
for note in ¬es {
|
||||
println!(" note: {note}");
|
||||
}
|
||||
if touched.is_empty() {
|
||||
println!("nothing to do: no note is carrying a citation");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
if dry_run {
|
||||
println!("\nnothing written");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
println!(
|
||||
"\nrewrote {} file(s)\n\nNext:\n coursebank validate\n coursebank references list\n\n\
|
||||
An issue number is never inferred, not even from a DOI that encodes one, so add those \
|
||||
by hand.",
|
||||
touched.len()
|
||||
);
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// Fills in the stored variant column.
|
||||
fn migrate_variants(cli: &Cli, dry_run: bool) -> Result<Outcome> {
|
||||
let touched = migrate::store_variants(&cli.course, !dry_run)?;
|
||||
if touched.is_empty() {
|
||||
println!("nothing to fill in: every stored row already names its variant");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
for (path, n) in &touched {
|
||||
println!(" {:<44} {n:>5} row(s)", path.display());
|
||||
}
|
||||
if dry_run {
|
||||
println!("\nnothing written");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
println!("\nrewrote {} data file(s)", touched.len());
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// Drops the version fields 2.0 ignores.
|
||||
fn migrate_stems(cli: &Cli, dry_run: bool) -> Result<Outcome> {
|
||||
let touched = migrate::stems(&cli.course, !dry_run)?;
|
||||
if touched.is_empty() {
|
||||
println!("nothing to migrate: no `version:` or `history:` left to drop");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
for (path, n) in &touched {
|
||||
println!(" {:<44} {n:>5} line(s) dropped", path.display());
|
||||
}
|
||||
if dry_run {
|
||||
println!("\nnothing written");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
println!(
|
||||
"\nrewrote {} file(s)\n\nNext:\n coursebank validate\n\nFrom here, rewording a stem \
|
||||
is an error rather than a version bump: give the new\n wording a new id and \
|
||||
`supersedes:` the old one.",
|
||||
touched.len()
|
||||
);
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// Rewrites option letters as names, showing every name before writing.
|
||||
fn migrate_options(cli: &Cli, dry_run: bool) -> Result<Outcome> {
|
||||
let (map, problems) = migrate::options_plan(&cli.course)?;
|
||||
|
||||
for (item, options) in &map {
|
||||
println!("{item}");
|
||||
for (letter, name) in options {
|
||||
println!(" {letter} -> {name}");
|
||||
}
|
||||
}
|
||||
|
||||
if !problems.is_empty() {
|
||||
println!("\n{} item(s) need naming by hand:", problems.len());
|
||||
for problem in &problems {
|
||||
println!(" - {problem}");
|
||||
}
|
||||
}
|
||||
if map.is_empty() {
|
||||
println!("nothing to migrate: every option is already named");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
|
||||
let touched = migrate::apply_options(&cli.course, &map, !dry_run)?;
|
||||
println!();
|
||||
for (path, n) in &touched {
|
||||
println!(" {:<44} {n:>5} rename(s)", path.display());
|
||||
}
|
||||
if dry_run {
|
||||
println!("\nnothing written");
|
||||
return Ok(if problems.is_empty() {
|
||||
Outcome::Ok
|
||||
} else {
|
||||
Outcome::Findings
|
||||
});
|
||||
}
|
||||
println!(
|
||||
"\nrewrote {} file(s)\n\nSeals keep their letters on purpose; see `coursebank migrate \
|
||||
--help`.\nNext:\n coursebank validate\n coursebank lint",
|
||||
touched.len()
|
||||
);
|
||||
Ok(if problems.is_empty() {
|
||||
Outcome::Ok
|
||||
} else {
|
||||
Outcome::Findings
|
||||
})
|
||||
}
|
||||
|
||||
/// Rewrites pre-2.0 bank-qualified item ids everywhere they are stored.
|
||||
fn migrate_ids(cli: &Cli, dry_run: bool) -> Result<Outcome> {
|
||||
let files = migrate::qualified_ids(&cli.course)?;
|
||||
for (path, n, _) in &files {
|
||||
println!(" {:<44} {n:>5} id(s)", path.display());
|
||||
}
|
||||
if !dry_run {
|
||||
migrate::apply_ids(&cli.course, &files)?;
|
||||
}
|
||||
|
||||
let data = migrate::store_ids(&cli.course, !dry_run)?;
|
||||
for (path, n) in &data {
|
||||
println!(" {:<44} {n:>5} row(s)", path.display());
|
||||
}
|
||||
|
||||
if files.is_empty() && data.is_empty() {
|
||||
println!("nothing to migrate: every item id already names the item course-wide");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
if dry_run {
|
||||
println!("\nnothing written");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
println!(
|
||||
"\nrewrote {} file(s) and {} data file(s)\n\nNext:\n coursebank validate\n \
|
||||
coursebank analyze items --all",
|
||||
files.len(),
|
||||
data.len()
|
||||
);
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// Lists the fragments a course is assembled from, with what each defines.
|
||||
fn course_files(cli: &Cli) -> Result<Outcome> {
|
||||
let layout = Layout::new(&cli.course);
|
||||
let course = CourseFile::load_dir(&cli.course)?;
|
||||
|
||||
for (path, role) in fragment::files(&layout)? {
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
let shown = path.strip_prefix(&cli.course).unwrap_or(&path);
|
||||
let mut defines: Vec<String> = Vec::new();
|
||||
for section in Section::ALL {
|
||||
let n = course
|
||||
.origins
|
||||
.iter()
|
||||
.filter(|((s, _), p)| *s == section && p.as_path() == shown)
|
||||
.count();
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
defines.push(match section {
|
||||
// These are declared once for the whole course, so a count
|
||||
// would always be 1 and would read as though it could be more.
|
||||
Section::Course | Section::Policy => section.key().to_string(),
|
||||
_ => format!("{n} {}", section.key()),
|
||||
});
|
||||
}
|
||||
println!(
|
||||
"{:<40} {:<11} {}",
|
||||
shown.display(),
|
||||
role.label(),
|
||||
defines.join(", ")
|
||||
);
|
||||
}
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// Prints or writes the merged course.
|
||||
fn course_build(cli: &Cli, out: Option<&Path>) -> Result<Outcome> {
|
||||
let course = CourseFile::load_dir(&cli.course)?;
|
||||
match out {
|
||||
Some(path) => {
|
||||
course.write_resolved(path)?;
|
||||
println!(
|
||||
"wrote {} from {} file(s)",
|
||||
path.display(),
|
||||
course.fragment_paths().len()
|
||||
);
|
||||
}
|
||||
None => print!("{}", yaml::to_string(&course)?),
|
||||
}
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
|
||||
/// Says which file defines an id.
|
||||
fn course_where(cli: &Cli, id: &str) -> Result<Outcome> {
|
||||
let course = CourseFile::load_dir(&cli.course)?;
|
||||
match course.origin(id) {
|
||||
Some((section, path)) => {
|
||||
println!(
|
||||
"{} defines `{id}` under `{}`",
|
||||
path.display(),
|
||||
section.key()
|
||||
);
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
None => Err(Error::Unresolved {
|
||||
kind: "id",
|
||||
id: id.to_string(),
|
||||
context: Some(cli.course.display().to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits `course.yaml` into fragments, then checks the result reassembles.
|
||||
fn course_split(cli: &Cli, dry_run: bool) -> Result<Outcome> {
|
||||
let plan = migrate::split_plan(&cli.course)?;
|
||||
|
||||
println!("{} file(s):", plan.files.len());
|
||||
for (path, lines) in plan.lines() {
|
||||
let summary = plan
|
||||
.files
|
||||
.iter()
|
||||
.find(|f| f.path == path)
|
||||
.map(|f| f.summary.clone())
|
||||
.unwrap_or_default();
|
||||
println!(" {:<44} {lines:>5} lines {summary}", path.display());
|
||||
}
|
||||
for note in &plan.notes {
|
||||
println!("\nnote: {note}");
|
||||
}
|
||||
|
||||
if dry_run {
|
||||
println!("\nnothing written");
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
|
||||
// Loaded before anything is written, since it is the thing the result is
|
||||
// checked against.
|
||||
let before = CourseFile::load(&Layout::new(&cli.course).course_file())?;
|
||||
let written = migrate::apply(&cli.course, &plan)?;
|
||||
println!("\nwrote {} file(s)", written.len());
|
||||
|
||||
let after = CourseFile::load_dir(&cli.course)?;
|
||||
let diffs = migrate::differences(&before, &after)?;
|
||||
if diffs.is_empty() {
|
||||
println!(
|
||||
"reassembled and compared against {COURSE_FILE}.bak: identical\n\nNext:\n \
|
||||
coursebank validate\n coursebank schema\n git add -A && git diff --cached --stat"
|
||||
);
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
|
||||
println!(
|
||||
"\n{} difference(s) between the original and the reassembled course:",
|
||||
diffs.len()
|
||||
);
|
||||
for diff in &diffs {
|
||||
println!(" - {diff}");
|
||||
}
|
||||
println!(
|
||||
"\nThe original is at {COURSE_FILE}.bak. Restore it with\n mv {COURSE_FILE}.bak \
|
||||
{COURSE_FILE} && rm -r lectures objectives {}",
|
||||
fragment::REFERENCES_FILE
|
||||
);
|
||||
Ok(Outcome::Findings)
|
||||
}
|
||||
|
||||
/// `references`: list the bibliography, or export it in a citation format.
|
||||
pub(crate) fn references(cli: &Cli, sub: &ReferencesCommand) -> Result<Outcome> {
|
||||
let course = CourseFile::load_dir(&cli.course)?;
|
||||
|
||||
match sub {
|
||||
ReferencesCommand::List => {
|
||||
let mut unreachable = 0;
|
||||
for (key, reference) in &course.references {
|
||||
let link = match reference.href(None, None) {
|
||||
Some(url) => url,
|
||||
None => {
|
||||
unreachable += 1;
|
||||
"(no link)".to_string()
|
||||
}
|
||||
};
|
||||
println!(
|
||||
"{:<28} {:<10} {:<9} {:<6} {link}",
|
||||
truncate(key, 27),
|
||||
format!("{:?}", reference.kind).to_lowercase(),
|
||||
format!("{:?}", reference.role).to_lowercase(),
|
||||
reference.label_or("-"),
|
||||
);
|
||||
}
|
||||
if unreachable > 0 && !cli.quiet {
|
||||
println!(
|
||||
"\n{unreachable} work(s) with no link. A student report can name one but \
|
||||
cannot send anyone to it; for a manuscript, add its `doi`."
|
||||
);
|
||||
}
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
ReferencesCommand::Export { format, out } => {
|
||||
let format = format.as_format();
|
||||
let text = references::render(&course, format)?;
|
||||
match out {
|
||||
Some(path) => {
|
||||
yaml::write_text(path, &text)?;
|
||||
println!(
|
||||
"wrote {} ({} work(s) as {})",
|
||||
path.display(),
|
||||
course.references.len(),
|
||||
format.label()
|
||||
);
|
||||
}
|
||||
None => print!("{text}"),
|
||||
}
|
||||
Ok(Outcome::Ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What reconciling [`GITIGNORE`] against a file already on disk would do.
|
||||
struct GitignoreMerge {
|
||||
/// The file to write. Identical to the input when nothing was missing.
|
||||
@@ -260,6 +688,13 @@ pub(crate) fn validate(cli: &Cli) -> Result<Outcome> {
|
||||
}
|
||||
}
|
||||
|
||||
let seals = coursebank::seal::SealFile::load_all(&catalog.layout.seals())?;
|
||||
all.extend(catalog.validate_seals(&seals));
|
||||
|
||||
// The statistics are kept in a different file from the questions they
|
||||
// describe, so the link between them is worth checking rather than assuming.
|
||||
all.extend(catalog.calibration.validate(&catalog));
|
||||
|
||||
if all.is_empty() {
|
||||
if !cli.quiet {
|
||||
println!(
|
||||
@@ -387,15 +822,29 @@ pub(crate) fn catalog(cli: &Cli, args: &CatalogArgs) -> Result<Outcome> {
|
||||
let coverage = catalog.coverage();
|
||||
println!("\nObjective coverage:");
|
||||
println!(
|
||||
" {:<40} {:>6} {:>6} MAX LEVEL",
|
||||
"OBJECTIVE", "ITEMS", "READY"
|
||||
" {:<44} {:>6} {:>6} {:>7} MAX LEVEL",
|
||||
"OBJECTIVE / TARGET", "ITEMS", "READY", "TARGETS"
|
||||
);
|
||||
for row in &coverage.rows {
|
||||
// Objectives carry the totals and are the tier a blueprint is
|
||||
// written at, so they are the rows to scan; the indented target rows
|
||||
// say where inside each one the items sit.
|
||||
let label = if row.tier == Tier::Objective {
|
||||
truncate(&row.id, 44)
|
||||
} else {
|
||||
truncate(&format!(" - {}", row.id), 44)
|
||||
};
|
||||
let targets = if row.targets > 0 {
|
||||
format!("{}/{}", row.targets_covered, row.targets)
|
||||
} else {
|
||||
"-".to_string()
|
||||
};
|
||||
println!(
|
||||
" {:<40} {:>6} {:>6} {}",
|
||||
truncate(&row.objective, 40),
|
||||
" {:<44} {:>6} {:>6} {:>7} {}",
|
||||
label,
|
||||
row.total,
|
||||
row.assemblable,
|
||||
targets,
|
||||
row.max_level
|
||||
.map(|l| l.code().to_string())
|
||||
.unwrap_or_else(|| "-".into())
|
||||
|
||||
+2
-1
@@ -337,6 +337,7 @@ pub fn ingest(
|
||||
item_number: number,
|
||||
item_ref,
|
||||
item_version: None,
|
||||
variant: None,
|
||||
selected,
|
||||
selected_source: Vec::new(),
|
||||
eliminated: Vec::new(),
|
||||
@@ -347,7 +348,7 @@ pub fn ingest(
|
||||
score,
|
||||
response_time_seconds: None,
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
learning_targets: Vec::new(),
|
||||
topics: Vec::new(),
|
||||
bonus: false,
|
||||
dropped: false,
|
||||
|
||||
+50
-6
@@ -81,6 +81,11 @@ pub enum Numbering {
|
||||
Recorded,
|
||||
}
|
||||
|
||||
/// The form marker put on a row that could not be translated, so it can be
|
||||
/// removed after the borrow on `set.rows` ends. No real form id can collide
|
||||
/// with it: form ids come from the record and are short labels like `A`.
|
||||
const UNMAPPED: &str = "\u{1f}unmapped";
|
||||
|
||||
/// One question's mapping on one form.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QuestionMap {
|
||||
@@ -243,7 +248,8 @@ impl FormDecoder {
|
||||
for (index, placement) in printed.iter().enumerate() {
|
||||
let entry = catalog.require(&placement.item)?;
|
||||
let item = &entry.item;
|
||||
let order = select::option_order(form, &placement.item, item.options.len());
|
||||
let shown = item.administered(&placement.key, &placement.distractors);
|
||||
let order = select::option_order(form, &placement.item, shown.len());
|
||||
|
||||
let canonical_key: BTreeSet<String> = if placement.key.is_empty() {
|
||||
item.key_letters().into_iter().collect()
|
||||
@@ -255,8 +261,7 @@ impl FormDecoder {
|
||||
let mut to_printed = BTreeMap::new();
|
||||
let mut printed_key = Vec::new();
|
||||
for (position, source_index) in order.iter().enumerate() {
|
||||
let canonical = item
|
||||
.options
|
||||
let canonical = shown
|
||||
.get(*source_index)
|
||||
.map(|c| c.id.clone())
|
||||
.unwrap_or_else(|| printed_letter(*source_index));
|
||||
@@ -354,6 +359,11 @@ impl FormDecoder {
|
||||
self.by_position.len()
|
||||
}
|
||||
|
||||
/// Every recorded question number this form carries.
|
||||
pub fn numbers(&self) -> impl Iterator<Item = u32> + '_ {
|
||||
self.by_position.values().map(|q| q.number)
|
||||
}
|
||||
|
||||
/// Whether the form prints nothing, which means the record is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.by_position.is_empty()
|
||||
@@ -572,9 +582,17 @@ pub fn form_warning(claimed: &str, fits: &[FormFit]) -> Option<String> {
|
||||
pub fn apply(set: &mut ResponseSet, decoder: &FormDecoder, numbering: Numbering) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
let mut unmapped_positions: BTreeSet<u32> = BTreeSet::new();
|
||||
let mut colliding_positions: BTreeSet<u32> = BTreeSet::new();
|
||||
let mut unmapped_letters: BTreeSet<String> = BTreeSet::new();
|
||||
let mut translated = 0usize;
|
||||
|
||||
// Numbers this form really uses. An untranslated row whose raw number is one
|
||||
// of these would silently masquerade as that question, and two rows would
|
||||
// then share a number: one the student's answer to it, one an answer to
|
||||
// something else entirely. Nothing downstream can tell them apart, so the
|
||||
// collision has to be caught here.
|
||||
let recorded: BTreeSet<u32> = decoder.numbers().collect();
|
||||
|
||||
for row in &mut set.rows {
|
||||
let belongs = row
|
||||
.form
|
||||
@@ -587,6 +605,13 @@ pub fn apply(set: &mut ResponseSet, decoder: &FormDecoder, numbering: Numbering)
|
||||
|
||||
let Some(map) = decoder.lookup(row.item_number, numbering) else {
|
||||
unmapped_positions.insert(row.item_number);
|
||||
if recorded.contains(&row.item_number) {
|
||||
colliding_positions.insert(row.item_number);
|
||||
// Marked so the row can be discarded below. Attributing it to
|
||||
// the question that legitimately holds this number would corrupt
|
||||
// that question's statistics.
|
||||
row.form = Some(UNMAPPED.to_string());
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -615,9 +640,27 @@ pub fn apply(set: &mut ResponseSet, decoder: &FormDecoder, numbering: Numbering)
|
||||
if !unmapped_positions.is_empty() {
|
||||
let list: Vec<String> = unmapped_positions.iter().map(|n| n.to_string()).collect();
|
||||
warnings.push(format!(
|
||||
"form {}: question(s) {} are in the export but not on this form; they were left \
|
||||
untranslated",
|
||||
"form {}: question(s) {} are in the export but not on this form ({} printed). The \
|
||||
export may have been taken before a question was dropped, or from a different \
|
||||
form.",
|
||||
decoder.form,
|
||||
list.join(", "),
|
||||
decoder.len()
|
||||
));
|
||||
}
|
||||
if !colliding_positions.is_empty() {
|
||||
let list: Vec<String> = colliding_positions.iter().map(|n| n.to_string()).collect();
|
||||
let discarded = set.rows.len();
|
||||
set.rows.retain(|row| row.form.as_deref() != Some(UNMAPPED));
|
||||
warnings.push(format!(
|
||||
"form {}: {} response(s) at position(s) {} could not be translated, and their raw \
|
||||
numbers are numbers this form does use. Keeping them would have given those \
|
||||
questions two different answers each, so they were discarded. This is the shape of \
|
||||
an export made before a question was dropped: re-export the responses from the \
|
||||
administration you sealed, or re-run with --recorded-numbers if the export already \
|
||||
carries recorded numbers.",
|
||||
decoder.form,
|
||||
discarded - set.rows.len(),
|
||||
list.join(", ")
|
||||
));
|
||||
}
|
||||
@@ -695,6 +738,7 @@ mod tests {
|
||||
form_position: None,
|
||||
item_ref: None,
|
||||
item_version: None,
|
||||
variant: None,
|
||||
selected: vec![selected.into()],
|
||||
eliminated: Vec::new(),
|
||||
selected_source: Vec::new(),
|
||||
@@ -705,7 +749,7 @@ mod tests {
|
||||
score: 0.0,
|
||||
response_time_seconds: None,
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
learning_targets: Vec::new(),
|
||||
topics: Vec::new(),
|
||||
bonus: false,
|
||||
dropped: false,
|
||||
|
||||
@@ -645,6 +645,7 @@ pub fn to_responses(questions: &[Question], ctx: &Context) -> Import {
|
||||
item_number: q.number,
|
||||
item_ref: None,
|
||||
item_version: None,
|
||||
variant: None,
|
||||
selected,
|
||||
selected_source: Vec::new(),
|
||||
eliminated,
|
||||
@@ -655,7 +656,7 @@ pub fn to_responses(questions: &[Question], ctx: &Context) -> Import {
|
||||
score: row.score,
|
||||
response_time_seconds: None,
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
learning_targets: Vec::new(),
|
||||
topics: Vec::new(),
|
||||
bonus,
|
||||
dropped: false,
|
||||
|
||||
+108
-11
@@ -75,6 +75,16 @@ pub struct Response {
|
||||
/// The item version as administered.
|
||||
pub item_version: Option<u32>,
|
||||
|
||||
/// The variant administered: which option set this row's student saw.
|
||||
///
|
||||
/// The grouping key for pooled statistics. Set at ingest from the
|
||||
/// assessment record, and stored so that anything reading the Parquet
|
||||
/// without this tool can group the same way — a store that only has
|
||||
/// `item_ref` cannot tell two option sets of one stem apart, and will
|
||||
/// average them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub variant: Option<String>,
|
||||
|
||||
/// Option letters the student chose.
|
||||
pub selected: Vec<String>,
|
||||
/// The selected options in the bank's own lettering, written at ingest by
|
||||
@@ -101,7 +111,7 @@ pub struct Response {
|
||||
/// 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>,
|
||||
pub learning_targets: Vec<String>,
|
||||
/// The item's topics, denormalized.
|
||||
pub topics: Vec<String>,
|
||||
/// Whether the item was bonus, and so excluded from the scored total.
|
||||
@@ -314,6 +324,49 @@ impl ResponseSet {
|
||||
per_item.values().sum()
|
||||
}
|
||||
|
||||
/// How many rows each option set of each item has.
|
||||
///
|
||||
/// What a calibration report needs to be honest about sample size: an item
|
||||
/// administered three times with three different option sets has three
|
||||
/// cells, not one, and reporting "n = 72" of it would be wrong three ways.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Row counts keyed by item id and variant, with an empty variant for rows
|
||||
/// that carry none.
|
||||
pub fn variants(&self) -> BTreeMap<(String, String), usize> {
|
||||
let mut out: BTreeMap<(String, String), usize> = BTreeMap::new();
|
||||
for row in &self.rows {
|
||||
let Some(item) = &row.item_ref else { continue };
|
||||
let key = (item.clone(), row.variant.clone().unwrap_or_default());
|
||||
*out.entry(key).or_insert(0) += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The rows for one option set of one item.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `item_ref` - the item id.
|
||||
/// * `variant` - the variant digest, or `None` for rows carrying none.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A set holding only those rows, keeping the warnings of the original.
|
||||
pub fn for_variant(&self, item_ref: &str, variant: Option<&str>) -> ResponseSet {
|
||||
ResponseSet {
|
||||
rows: self
|
||||
.rows
|
||||
.iter()
|
||||
.filter(|r| r.item_ref.as_deref() == Some(item_ref))
|
||||
.filter(|r| r.variant.as_deref() == variant)
|
||||
.cloned()
|
||||
.collect(),
|
||||
warnings: self.warnings.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the response matrix for psychometrics.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -416,6 +469,9 @@ impl ResponseSet {
|
||||
};
|
||||
r.item_ref = Some(p.item.clone());
|
||||
r.item_version = p.version;
|
||||
// Recorded when the record says so; derived from the option set
|
||||
// otherwise, which is the case for every administration before 2.0.
|
||||
r.variant = p.variant.clone();
|
||||
r.bonus = r.bonus || p.bonus;
|
||||
r.dropped = r.dropped || p.dropped;
|
||||
r.dropped_full_credit = r.dropped_full_credit || p.dropped_with_credit();
|
||||
@@ -434,11 +490,14 @@ impl ResponseSet {
|
||||
|
||||
if let Some(cat) = catalog {
|
||||
if let Some(entry) = cat.get(&p.item) {
|
||||
if r.variant.is_none() {
|
||||
r.variant = Some(p.variant_of(&entry.item));
|
||||
}
|
||||
r.level = Some(entry.item.level);
|
||||
r.learning_objectives = if p.learning_objectives.is_empty() {
|
||||
entry.item.learning_objectives.clone()
|
||||
r.learning_targets = if p.learning_targets.is_empty() {
|
||||
entry.item.learning_targets.clone()
|
||||
} else {
|
||||
p.learning_objectives.clone()
|
||||
p.learning_targets.clone()
|
||||
};
|
||||
r.topics = entry.item.topics.clone();
|
||||
if r.points_possible == 0.0 && !p.bonus {
|
||||
@@ -447,7 +506,7 @@ impl ResponseSet {
|
||||
}
|
||||
} else {
|
||||
r.level = p.level;
|
||||
r.learning_objectives = p.learning_objectives.clone();
|
||||
r.learning_targets = p.learning_targets.clone();
|
||||
}
|
||||
|
||||
// Apply the record's credit overrides, which is how a decision to
|
||||
@@ -636,6 +695,8 @@ pub struct FlatResponse {
|
||||
pub item_ref: String,
|
||||
/// The item version, 0 when unknown.
|
||||
pub item_version: u32,
|
||||
/// The administered variant, empty when unknown.
|
||||
pub variant: String,
|
||||
/// Comma-joined selected letters.
|
||||
pub selected: String,
|
||||
/// Comma-joined eliminated letters.
|
||||
@@ -652,8 +713,13 @@ pub struct FlatResponse {
|
||||
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 target ids.
|
||||
///
|
||||
/// The column keeps its original name. Renaming a column in a store that
|
||||
/// already holds collected administrations would make last term's responses
|
||||
/// unreadable, and no vocabulary improvement is worth that.
|
||||
#[serde(rename = "learning_objectives")]
|
||||
pub learning_targets: String,
|
||||
/// Comma-joined topics.
|
||||
pub topics: String,
|
||||
/// Whether the item was bonus.
|
||||
@@ -705,6 +771,7 @@ impl FlatResponse {
|
||||
item_number: r.item_number,
|
||||
item_ref: r.item_ref.clone().unwrap_or_default(),
|
||||
item_version: r.item_version.unwrap_or(0),
|
||||
variant: r.variant.clone().unwrap_or_default(),
|
||||
selected: r.selected.join(","),
|
||||
selected_source: r.selected_source.join(","),
|
||||
eliminated: r.eliminated.join(","),
|
||||
@@ -722,7 +789,7 @@ impl FlatResponse {
|
||||
.map(|s| format!("{s:.1}"))
|
||||
.unwrap_or_default(),
|
||||
level: r.level.map(|l| l.code()).unwrap_or(0),
|
||||
learning_objectives: r.learning_objectives.join(","),
|
||||
learning_targets: r.learning_targets.join(","),
|
||||
topics: r.topics.join(","),
|
||||
bonus: r.bonus,
|
||||
dropped: r.dropped,
|
||||
@@ -762,7 +829,11 @@ impl FlatResponse {
|
||||
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),
|
||||
// Canonicalized on read, so a term ingested before 2.0 pools with
|
||||
// one ingested after it instead of splitting into two items.
|
||||
item_ref: none_if_empty(&self.item_ref)
|
||||
.map(|id| crate::item::canonical_id(&id).to_string()),
|
||||
variant: none_if_empty(&self.variant),
|
||||
item_version: if self.item_version == 0 {
|
||||
None
|
||||
} else {
|
||||
@@ -782,7 +853,7 @@ impl FlatResponse {
|
||||
score: self.score,
|
||||
response_time_seconds: self.response_time_seconds.parse().ok(),
|
||||
level: Level::from_code(self.level),
|
||||
learning_objectives: split(&self.learning_objectives),
|
||||
learning_targets: split(&self.learning_targets),
|
||||
topics: split(&self.topics),
|
||||
bonus: self.bonus,
|
||||
dropped: self.dropped,
|
||||
@@ -820,6 +891,7 @@ mod tests {
|
||||
item_number: number,
|
||||
item_ref: None,
|
||||
item_version: None,
|
||||
variant: None,
|
||||
selected: vec!["A".into()],
|
||||
selected_source: vec![],
|
||||
eliminated: vec![],
|
||||
@@ -830,7 +902,7 @@ mod tests {
|
||||
score: credit * 2.0,
|
||||
response_time_seconds: None,
|
||||
level: None,
|
||||
learning_objectives: vec![],
|
||||
learning_targets: vec![],
|
||||
topics: vec![],
|
||||
bonus: false,
|
||||
dropped: false,
|
||||
@@ -838,6 +910,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_group_by_the_option_set_they_administered() {
|
||||
let mut set = ResponseSet::new();
|
||||
for (student, variant) in [("s1", "v1"), ("s2", "v1"), ("s3", "v2")] {
|
||||
let mut r = row(student, 1, 1.0);
|
||||
r.item_ref = Some("q-x".into());
|
||||
r.variant = Some(variant.into());
|
||||
set.rows.push(r);
|
||||
}
|
||||
// A row from before the column existed.
|
||||
let mut old = row("s4", 1, 1.0);
|
||||
old.item_ref = Some("q-x".into());
|
||||
set.rows.push(old);
|
||||
|
||||
let counts = set.variants();
|
||||
assert_eq!(counts[&("q-x".to_string(), "v1".to_string())], 2);
|
||||
assert_eq!(counts[&("q-x".to_string(), "v2".to_string())], 1);
|
||||
// Unknown groups on its own rather than joining either set.
|
||||
assert_eq!(counts[&("q-x".to_string(), String::new())], 1);
|
||||
|
||||
assert_eq!(set.for_variant("q-x", Some("v1")).rows.len(), 2);
|
||||
assert_eq!(set.for_variant("q-x", None).rows.len(), 1);
|
||||
assert_eq!(set.for_variant("q-other", Some("v1")).rows.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matrix_is_students_by_items() {
|
||||
let mut set = ResponseSet::new();
|
||||
|
||||
+70
-3
@@ -316,6 +316,70 @@ pub fn read_path(path: &Path) -> Result<ResponseSet> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a response file without turning its rows into [`Response`]s.
|
||||
///
|
||||
/// What a migration wants: the rows exactly as they sit on disk, so rewriting
|
||||
/// one column cannot disturb another through a round trip.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - the file to read.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The rows.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Other`] for an unrecognized extension, [`Error::Csv`] or
|
||||
/// [`Error::Other`] on a parse failure, and [`Error::FeatureDisabled`] for
|
||||
/// Parquet without the feature.
|
||||
pub fn read_flat(path: &Path) -> Result<Vec<FlatResponse>> {
|
||||
match Format::from_path(path) {
|
||||
Some(Format::Csv) => {
|
||||
let mut r = csv::Reader::from_path(path).map_err(|e| Error::Csv {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for rec in r.deserialize::<FlatResponse>() {
|
||||
out.push(rec.map_err(|e| Error::Csv {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
Some(Format::Parquet) => crate::store_parquet::read(path),
|
||||
None => Err(Error::Other(format!(
|
||||
"{} is not a response file; expected a .parquet or .csv",
|
||||
path.display()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes flat responses back to the file they came from.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - the destination, whose extension picks the format.
|
||||
/// * `rows` - the rows.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Other`] for an unrecognized extension and
|
||||
/// [`Error::FeatureDisabled`] for Parquet without the feature.
|
||||
pub fn write_flat(path: &Path, rows: &[FlatResponse]) -> Result<()> {
|
||||
match Format::from_path(path) {
|
||||
Some(Format::Csv) => write_csv(path, rows),
|
||||
Some(Format::Parquet) => write_parquet(path, rows),
|
||||
None => Err(Error::Other(format!(
|
||||
"{} is not a response file; expected a .parquet or .csv",
|
||||
path.display()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes flat responses as CSV.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -550,6 +614,7 @@ mod tests {
|
||||
item_number: number,
|
||||
item_ref: Some("bank::q-x-001".into()),
|
||||
item_version: Some(2),
|
||||
variant: None,
|
||||
selected: vec!["C".into()],
|
||||
selected_source: vec![],
|
||||
eliminated: vec![],
|
||||
@@ -560,7 +625,7 @@ mod tests {
|
||||
score: 1.5,
|
||||
response_time_seconds: None,
|
||||
level: None,
|
||||
learning_objectives: vec!["lo-a".into()],
|
||||
learning_targets: vec!["lo-a".into()],
|
||||
topics: vec![],
|
||||
bonus: false,
|
||||
dropped: false,
|
||||
@@ -591,9 +656,11 @@ mod tests {
|
||||
|
||||
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"));
|
||||
// Canonicalized on the way in: the row was written with a pre-2.0
|
||||
// `bank::item` key, and reading it yields the item it names.
|
||||
assert_eq!(back.rows[0].item_ref.as_deref(), Some("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()]);
|
||||
assert_eq!(back.rows[0].learning_targets, vec!["lo-a".to_string()]);
|
||||
|
||||
let all = store.read_all().unwrap();
|
||||
assert_eq!(all.rows.len(), 2);
|
||||
|
||||
@@ -49,6 +49,7 @@ pub fn schema() -> Schema {
|
||||
Field::new("item_number", DataType::UInt32, false),
|
||||
Field::new("item_ref", DataType::Utf8, false),
|
||||
Field::new("item_version", DataType::UInt32, false),
|
||||
Field::new("variant", DataType::Utf8, false),
|
||||
Field::new("selected", DataType::Utf8, false),
|
||||
Field::new("eliminated", DataType::Utf8, false),
|
||||
Field::new("correct", DataType::Utf8, false),
|
||||
@@ -57,6 +58,8 @@ pub fn schema() -> Schema {
|
||||
Field::new("score", DataType::Float64, false),
|
||||
Field::new("response_time_seconds", DataType::Utf8, false),
|
||||
Field::new("level", DataType::UInt32, false),
|
||||
// The stored column name predates the objective/target rename and is left
|
||||
// alone: an existing parquet file has to keep loading.
|
||||
Field::new("learning_objectives", DataType::Utf8, false),
|
||||
Field::new("topics", DataType::Utf8, false),
|
||||
Field::new("bonus", DataType::Boolean, false),
|
||||
@@ -112,6 +115,7 @@ fn to_batch(rows: &[FlatResponse]) -> Result<RecordBatch> {
|
||||
u32c(|r| r.item_number),
|
||||
s(|r| &r.item_ref),
|
||||
u32c(|r| r.item_version),
|
||||
s(|r| &r.variant),
|
||||
s(|r| &r.selected),
|
||||
s(|r| &r.eliminated),
|
||||
s(|r| &r.correct),
|
||||
@@ -120,7 +124,7 @@ fn to_batch(rows: &[FlatResponse]) -> Result<RecordBatch> {
|
||||
f64c(|r| r.score),
|
||||
s(|r| &r.response_time_seconds),
|
||||
u32c(|r| r.level as u32),
|
||||
s(|r| &r.learning_objectives),
|
||||
s(|r| &r.learning_targets),
|
||||
s(|r| &r.topics),
|
||||
boolc(|r| r.bonus),
|
||||
boolc(|r| r.dropped),
|
||||
@@ -277,6 +281,9 @@ fn from_batch(batch: &RecordBatch, path: &Path) -> Result<Vec<FlatResponse>> {
|
||||
let item_number = uints("item_number")?;
|
||||
let item_ref = strings("item_ref")?;
|
||||
let item_version = uints("item_version")?;
|
||||
// Added after the first stores were written, so absent rather than fatal in
|
||||
// a file from before 2.0; `coursebank migrate variants` fills it in.
|
||||
let variant = optional_strings("variant");
|
||||
let selected = strings("selected")?;
|
||||
let eliminated = strings("eliminated")?;
|
||||
let correct = strings("correct")?;
|
||||
@@ -285,7 +292,7 @@ fn from_batch(batch: &RecordBatch, path: &Path) -> Result<Vec<FlatResponse>> {
|
||||
let score = floats("score")?;
|
||||
let response_time_seconds = strings("response_time_seconds")?;
|
||||
let level = uints("level")?;
|
||||
let learning_objectives = strings("learning_objectives")?;
|
||||
let learning_targets = strings("learning_objectives")?;
|
||||
let topics = strings("topics")?;
|
||||
let bonus = bools("bonus")?;
|
||||
let dropped = bools("dropped")?;
|
||||
@@ -312,6 +319,7 @@ fn from_batch(batch: &RecordBatch, path: &Path) -> Result<Vec<FlatResponse>> {
|
||||
item_number: item_number.value(i),
|
||||
item_ref: item_ref.value(i).to_string(),
|
||||
item_version: item_version.value(i),
|
||||
variant: variant.map(|c| c.value(i).to_string()).unwrap_or_default(),
|
||||
selected: selected.value(i).to_string(),
|
||||
selected_source: selected_source
|
||||
.map(|a| a.value(i).to_string())
|
||||
@@ -326,7 +334,7 @@ fn from_batch(batch: &RecordBatch, path: &Path) -> Result<Vec<FlatResponse>> {
|
||||
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(),
|
||||
learning_targets: learning_targets.value(i).to_string(),
|
||||
topics: topics.value(i).to_string(),
|
||||
bonus: bonus.value(i),
|
||||
dropped: dropped.value(i),
|
||||
@@ -364,6 +372,7 @@ mod tests {
|
||||
item_number: number,
|
||||
item_ref: "bank::q-a-001".into(),
|
||||
item_version: 3,
|
||||
variant: "4c81fa".into(),
|
||||
selected: "C".into(),
|
||||
eliminated: String::new(),
|
||||
correct: "1".into(),
|
||||
@@ -372,7 +381,7 @@ mod tests {
|
||||
score: 1.5,
|
||||
response_time_seconds: "42.0".into(),
|
||||
level: 3,
|
||||
learning_objectives: "lo-a,lo-b".into(),
|
||||
learning_targets: "lo-a,lo-b".into(),
|
||||
topics: "kinetics".into(),
|
||||
bonus: false,
|
||||
dropped: false,
|
||||
@@ -404,7 +413,7 @@ mod tests {
|
||||
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].learning_targets, "lo-a,lo-b");
|
||||
assert_eq!(back[0].credit, 1.0);
|
||||
assert_eq!(back[0].level, 3);
|
||||
assert!(!back[0].bonus);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! | [`site`] | a Quarto partial and an encrypted bundle | a course page with password-gated solutions |
|
||||
//! | [`report`] | Markdown and HTML | students, and yourself |
|
||||
//! | [`lecture`] | Markdown | the reading list on the course website |
|
||||
//! | [`references`] | Hayagriva, CSL-JSON, BibTeX | Typst, Zotero, LaTeX |
|
||||
//!
|
||||
//! [`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
|
||||
@@ -26,6 +27,7 @@
|
||||
pub mod lecture;
|
||||
pub mod practice;
|
||||
pub mod qti;
|
||||
pub mod references;
|
||||
pub mod report;
|
||||
pub mod site;
|
||||
pub mod typst;
|
||||
|
||||
+283
-52
@@ -9,10 +9,13 @@
|
||||
//! hand. Two copies of the same prose drift within a term; one copy and a build
|
||||
//! step do not.
|
||||
//!
|
||||
//! Two pages come out of here: the lecture's learning objectives with their
|
||||
//! targets enumerated under each, and its readings.
|
||||
//!
|
||||
//! [`Style::Quarto`] reproduces the definition-list shape a Quarto page wants,
|
||||
//! with `_(LO 4, 7)_` numbering resolved from [`CourseFile::lecture_objectives`].
|
||||
//! Those numbers are positional and so cannot be authored: inserting an objective
|
||||
//! renumbers everything after it. They are computed here and never stored.
|
||||
//! with `_(T 4, 7)_` numbering resolved from [`numbered_targets`]. Those numbers
|
||||
//! are positional and so cannot be authored: inserting a target renumbers
|
||||
//! everything after it. They are computed here and never stored.
|
||||
//!
|
||||
//! What this module does not do is invent prose. Everything printed comes from
|
||||
//! `summary`, `focus`, and `skip` on the reading, in that order, and a reading with
|
||||
@@ -33,12 +36,145 @@ pub enum Style {
|
||||
Plain,
|
||||
}
|
||||
|
||||
/// Renders the objectives for one lecture, grouped by level.
|
||||
/// How a lecture's targets group under its objectives.
|
||||
///
|
||||
/// The numbering here and the `_(LO 4, 7)_` lists in [`readings_markdown`] come
|
||||
/// from the same call to [`CourseFile::lecture_objectives`], so they cannot
|
||||
/// disagree. Generating one half of the page and hand-writing the other is how you
|
||||
/// get a note pointing at LO 8 when LO 8 has become LO 9.
|
||||
/// One function feeds every page, so the printed order and the numbering cannot
|
||||
/// come apart.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `course` - the loaded course file.
|
||||
/// * `lecture` - the lecture id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The groups as `(objective id, its targets in this lecture)`, and the
|
||||
/// leftovers: targets whose objective this lecture does not teach, and
|
||||
/// objectives that have no targets and so stand as their own.
|
||||
fn target_layout<'a>(
|
||||
course: &'a CourseFile,
|
||||
lecture: &str,
|
||||
) -> (Vec<(&'a str, Vec<&'a str>)>, Vec<&'a str>) {
|
||||
let targets = course.lecture_targets(lecture);
|
||||
let mut groups: Vec<(&str, Vec<&str>)> = Vec::new();
|
||||
for objective in course.lecture_objectives(lecture) {
|
||||
let mine: Vec<&str> = course
|
||||
.targets(objective)
|
||||
.into_iter()
|
||||
.filter(|t| targets.contains(t))
|
||||
.collect();
|
||||
if !mine.is_empty() {
|
||||
groups.push((objective, mine));
|
||||
}
|
||||
}
|
||||
let grouped: Vec<&str> = groups
|
||||
.iter()
|
||||
.flat_map(|(_, ts)| ts.iter().copied())
|
||||
.collect();
|
||||
let leftovers: Vec<&str> = targets
|
||||
.into_iter()
|
||||
.filter(|t| !grouped.contains(t))
|
||||
.collect();
|
||||
(groups, leftovers)
|
||||
}
|
||||
|
||||
/// Whether a lecture's registry entries use the second tier.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `course` - the loaded course file.
|
||||
/// * `lecture` - the lecture id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` when any entry the lecture names is a target.
|
||||
fn is_tiered(course: &CourseFile, lecture: &str) -> bool {
|
||||
course
|
||||
.lecture_entries(lecture)
|
||||
.iter()
|
||||
.any(|id| course.is_target(id))
|
||||
}
|
||||
|
||||
/// The entries a lecture's pages number, in the order they print.
|
||||
///
|
||||
/// Numbering is a property of the *targets* page, because that is the tier a
|
||||
/// reading serves: a section of a textbook backs a specific performance, not a
|
||||
/// whole objective. An untiered lecture numbers its objectives instead, since
|
||||
/// there each objective is its own target.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `course` - the loaded course file.
|
||||
/// * `lecture` - the lecture id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Registry ids in printed order.
|
||||
pub fn numbered_targets(course: &CourseFile, lecture: &str) -> Vec<String> {
|
||||
if !is_tiered(course, lecture) {
|
||||
// Untiered page: level groups, in the order they print, which is the
|
||||
// numbering this page has always had.
|
||||
return by_level(course, &course.lecture_entries(lecture))
|
||||
.into_iter()
|
||||
.flat_map(|(_, members)| members)
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
}
|
||||
let (groups, leftovers) = target_layout(course, lecture);
|
||||
groups
|
||||
.into_iter()
|
||||
.flat_map(|(_, targets)| targets)
|
||||
.chain(leftovers)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Groups ids by the level they are assessed up to, in taxonomy order.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `course` - the loaded course file.
|
||||
/// * `ids` - the ids to group.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Non-empty groups, with whatever declares no ceiling last under `None`.
|
||||
fn by_level<'a>(course: &CourseFile, ids: &[&'a str]) -> Vec<(Option<Level>, Vec<&'a str>)> {
|
||||
let mut groups: Vec<(Option<Level>, Vec<&str>)> =
|
||||
Level::ALL.iter().map(|l| (Some(*l), Vec::new())).collect();
|
||||
groups.push((None, Vec::new()));
|
||||
for id in ids {
|
||||
let ceiling = course.effective_level_ceiling(id);
|
||||
if let Some(slot) = groups.iter_mut().find(|(level, _)| *level == ceiling) {
|
||||
slot.1.push(id);
|
||||
}
|
||||
}
|
||||
groups.retain(|(_, members)| !members.is_empty());
|
||||
groups
|
||||
}
|
||||
|
||||
/// Renders the learning objectives for one lecture, with each objective's
|
||||
/// targets enumerated beneath it.
|
||||
///
|
||||
/// One page rather than two. The objective and its targets are the same claim
|
||||
/// at two grains, so printing them in separate sections would list every target
|
||||
/// twice and leave a reader matching them up by hand.
|
||||
///
|
||||
/// The objective is set in bold rather than as a heading, and the targets are
|
||||
/// numbered: the numbers are what a reading's `_(T 4, 7)_` points at, and they
|
||||
/// run continuously down the page rather than restarting under each objective,
|
||||
/// because a reading cites a target without caring which objective it serves.
|
||||
/// Pandoc's `(@)` example lists continue numbering across the paragraphs
|
||||
/// between them, which is why the objective lines can sit in the middle of the
|
||||
/// sequence without breaking it.
|
||||
///
|
||||
/// An objective with no targets of its own is printed as a numbered line
|
||||
/// instead, since it stands as its own target and a reading may cite it.
|
||||
/// Bolding it and then repeating it as its only target is the duplication this
|
||||
/// layout exists to avoid.
|
||||
///
|
||||
/// On a lecture whose entries are all objectives, the output is what it has
|
||||
/// always been, grouped by level.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
@@ -48,49 +184,68 @@ pub enum Style {
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The Markdown, ending in a newline. Objectives with no `level_ceiling` are
|
||||
/// grouped last under no heading.
|
||||
/// The Markdown, ending in a newline. On an untiered lecture, objectives with
|
||||
/// no `level_ceiling` are grouped last under no heading.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Unresolved`] when the lecture id is not registered.
|
||||
pub fn objectives_markdown(course: &CourseFile, lecture: &str, style: Style) -> Result<String> {
|
||||
course.lecture(lecture, "lecture page")?;
|
||||
let ids = course.lecture_objectives(lecture);
|
||||
|
||||
let mut out = String::from("## Learning objectives\n\n");
|
||||
out.push_str("After this lecture, you should be able to do the following.\n\n");
|
||||
|
||||
// Levels in taxonomy order, then whatever declares no ceiling.
|
||||
let mut groups: Vec<(Option<Level>, Vec<&str>)> =
|
||||
Level::ALL.iter().map(|l| (Some(*l), Vec::new())).collect();
|
||||
groups.push((None, Vec::new()));
|
||||
for id in &ids {
|
||||
let ceiling = course.learning_objectives[*id].level_ceiling;
|
||||
if let Some(slot) = groups.iter_mut().find(|(level, _)| *level == ceiling) {
|
||||
slot.1.push(id);
|
||||
if !is_tiered(course, lecture) {
|
||||
// Levels in taxonomy order, then whatever declares no ceiling.
|
||||
for (level, members) in by_level(course, &course.lecture_entries(lecture)) {
|
||||
if let Some(level) = level {
|
||||
out.push_str(&format!("### {}\n\n", level.name()));
|
||||
}
|
||||
for id in members {
|
||||
out.push_str(&bullet(&course.text_for(id), style));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
|
||||
for (level, members) in &groups {
|
||||
if members.is_empty() {
|
||||
continue;
|
||||
let (groups, leftovers) = target_layout(course, lecture);
|
||||
for (objective, targets) in groups {
|
||||
out.push_str(&format!("**{}**\n\n", course.text_for(objective)));
|
||||
for target in targets {
|
||||
out.push_str(&bullet(&course.text_for(target), style));
|
||||
}
|
||||
if let Some(level) = level {
|
||||
out.push_str(&format!("### {}\n\n", level.name()));
|
||||
}
|
||||
for id in members {
|
||||
let text = course.objective_text(id);
|
||||
out.push_str(&match style {
|
||||
Style::Quarto => format!("(@) {text}\n"),
|
||||
Style::Plain => format!("1. {text}\n"),
|
||||
});
|
||||
out.push('\n');
|
||||
}
|
||||
// Targets whose objective this lecture does not teach, and objectives with
|
||||
// no targets, in the order `numbered_targets` expects them.
|
||||
if !leftovers.is_empty() {
|
||||
for id in leftovers {
|
||||
out.push_str(&bullet(&course.text_for(id), style));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One list item, numbered so a reading can point at it.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `text` - the objective or target text.
|
||||
/// * `style` - which flavour to emit.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The line, ending in a newline.
|
||||
fn bullet(text: &str, style: Style) -> String {
|
||||
match style {
|
||||
Style::Quarto => format!("(@) {text}\n"),
|
||||
Style::Plain => format!("1. {text}\n"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the readings for one lecture.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -112,9 +267,9 @@ pub fn readings_markdown(course: &CourseFile, lecture: &str, style: Style) -> Re
|
||||
let lec = course.lecture(lecture, "lecture page")?;
|
||||
|
||||
// Positional numbers for this page, so `{lo-id}` in a note and the trailing
|
||||
// `_(LO ...)_` agree with the objective list printed above them.
|
||||
let order = course.lecture_objectives(lecture);
|
||||
let number = |id: &str| order.iter().position(|o| *o == id).map(|i| i + 1);
|
||||
// `_(T ...)_` agree with the enumerated targets printed above them.
|
||||
let order = numbered_targets(course, lecture);
|
||||
let number = |id: &str| order.iter().position(|o| o == id).map(|i| i + 1);
|
||||
|
||||
let mut out = String::from("## Readings\n\n");
|
||||
for role in [ReadingRole::Assigned, ReadingRole::Supplemental] {
|
||||
@@ -177,8 +332,8 @@ fn entry(
|
||||
.flatten()
|
||||
.map(|prose| {
|
||||
course.expand_objective_refs(prose, |id| match number(id) {
|
||||
Some(n) => format!("LO {n}"),
|
||||
None => course.objective_text(id),
|
||||
Some(n) => format!("T {n}"),
|
||||
None => course.text_for(id),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -188,15 +343,12 @@ fn entry(
|
||||
if !body.is_empty() {
|
||||
out.push_str(&format!(": {}\n", body.join("\n")));
|
||||
}
|
||||
let mut numbers: Vec<usize> = reading
|
||||
.objectives
|
||||
.iter()
|
||||
.filter_map(|o| number(o))
|
||||
.collect();
|
||||
let mut numbers: Vec<usize> =
|
||||
reading.targets.iter().filter_map(|o| number(o)).collect();
|
||||
numbers.sort_unstable();
|
||||
if !numbers.is_empty() {
|
||||
let list: Vec<String> = numbers.iter().map(|n| n.to_string()).collect();
|
||||
out.push_str(&format!("<br>\n_(LO {})_\n", list.join(", ")));
|
||||
out.push_str(&format!("<br>\n_(T {})_\n", list.join(", ")));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
@@ -222,7 +374,7 @@ fn entry(
|
||||
///
|
||||
/// A linked citation when the location has a URL, and a plain one when it does not.
|
||||
fn heading(reading: &Reading, key: &str, reference: &Reference, style: Style) -> String {
|
||||
let label = reference.label.as_deref().unwrap_or(key);
|
||||
let label = reference.label_or(key);
|
||||
let locator = reading.locator.as_deref().unwrap_or("");
|
||||
let linked = match reading.resolve_url(reference) {
|
||||
Some(url) if !locator.is_empty() => format!("[{locator}]({url})"),
|
||||
@@ -239,13 +391,14 @@ fn heading(reading: &Reading, key: &str, reference: &Reference, style: Style) ->
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::course::{Lecture, Objective, ReferenceRole};
|
||||
use crate::course::{Lecture, Objective, ReferenceRole, Target};
|
||||
|
||||
/// A course with one lecture, two objectives, and one reference.
|
||||
fn course() -> CourseFile {
|
||||
let mut c = CourseFile::skeleton("BIOSC 1000", "Biochemistry", "2026f");
|
||||
c.lectures.clear();
|
||||
c.learning_objectives.clear();
|
||||
c.learning_targets.clear();
|
||||
|
||||
c.references.insert(
|
||||
"kuriyan2013molecules".into(),
|
||||
@@ -275,12 +428,13 @@ mod tests {
|
||||
date: None,
|
||||
unit: None,
|
||||
slides_url: None,
|
||||
teaches: Vec::new(),
|
||||
readings: vec![
|
||||
Reading {
|
||||
reference: Some("kuriyan2013molecules".into()),
|
||||
locator: Some("§6.1".into()),
|
||||
path: Some("6/A/#1".into()),
|
||||
objectives: vec!["lo-second".into(), "lo-first".into()],
|
||||
targets: vec!["lo-second".into(), "lo-first".into()],
|
||||
summary: Some("What a system is.".into()),
|
||||
focus: Some("A worked instance of {lo-first}.".into()),
|
||||
..Reading::default()
|
||||
@@ -290,7 +444,7 @@ mod tests {
|
||||
locator: Some("§1.9".into()),
|
||||
path: Some("1/B/#9".into()),
|
||||
role: ReadingRole::Supplemental,
|
||||
objectives: vec!["lo-second".into()],
|
||||
targets: vec!["lo-second".into()],
|
||||
summary: Some("Background.".into()),
|
||||
..Reading::default()
|
||||
},
|
||||
@@ -322,27 +476,27 @@ mod tests {
|
||||
|
||||
`KKW` [§6.1](https://example.org/kkw/6/A/#1)
|
||||
: What a system is.
|
||||
A worked instance of LO 1.
|
||||
A worked instance of T 1.
|
||||
<br>
|
||||
_(LO 1, 2)_
|
||||
_(T 1, 2)_
|
||||
|
||||
### Supplemental
|
||||
|
||||
`KKW` [§1.9](https://example.org/kkw/1/B/#9)
|
||||
: Background.
|
||||
<br>
|
||||
_(LO 2)_
|
||||
_(T 2)_
|
||||
|
||||
";
|
||||
assert_eq!(md, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_numbers_follow_teaching_order_not_id_order() {
|
||||
fn target_numbers_follow_teaching_order_not_id_order() {
|
||||
// `lo-second` sorts first alphabetically and second by `order`.
|
||||
let md = readings_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
|
||||
assert!(md.contains("_(LO 1, 2)_"));
|
||||
assert!(md.contains("A worked instance of LO 1."));
|
||||
assert!(md.contains("_(T 1, 2)_"));
|
||||
assert!(md.contains("A worked instance of T 1."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -374,6 +528,83 @@ After this lecture, you should be able to do the following.
|
||||
assert_eq!(md, expected);
|
||||
}
|
||||
|
||||
/// The fixture course with its two entries moved into the target registry
|
||||
/// under a new objective, which is the shape a migrated course has.
|
||||
fn tiered_course() -> CourseFile {
|
||||
let mut c = course();
|
||||
c.learning_objectives.insert(
|
||||
"lo-binding".into(),
|
||||
Objective {
|
||||
text: "Quantify binding.".into(),
|
||||
lectures: vec!["L1.1".into()],
|
||||
order: Some(0),
|
||||
..objective_defaults()
|
||||
},
|
||||
);
|
||||
// The readings cite `lo-second` and `lo-first`, so the ids are kept and
|
||||
// only the registry they live in changes. A real migration renames them
|
||||
// to the `t-` spelling and rewrites the citations with them.
|
||||
for (id, order) in [("lo-first", 1), ("lo-second", 2)] {
|
||||
let objective = c.learning_objectives.remove(id).expect("fixture");
|
||||
c.learning_targets.insert(
|
||||
id.into(),
|
||||
Target {
|
||||
text: objective.text,
|
||||
objective: "lo-binding".into(),
|
||||
lectures: objective.lectures,
|
||||
order: Some(order),
|
||||
level_ceiling: None,
|
||||
prerequisites: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
assessed: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_page_bolds_each_objective_and_enumerates_its_targets() {
|
||||
let md = objectives_markdown(&tiered_course(), "L1.1", Style::Quarto).expect("renders");
|
||||
let expected = "\
|
||||
## Learning objectives
|
||||
|
||||
After this lecture, you should be able to do the following.
|
||||
|
||||
**Quantify binding.**
|
||||
|
||||
(@) objective lo-first
|
||||
(@) objective lo-second
|
||||
|
||||
";
|
||||
assert_eq!(md, expected);
|
||||
// Each target appears once on the page, which is the point of merging
|
||||
// the two sections.
|
||||
assert_eq!(md.matches("objective lo-first").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_numbers_point_at_the_enumerated_targets() {
|
||||
// The numbers on the page and the ones a reading cites come from the
|
||||
// same function, so they cannot drift apart.
|
||||
let readings = readings_markdown(&tiered_course(), "L1.1", Style::Quarto).expect("renders");
|
||||
assert!(readings.contains("_(T 1, 2)_"), "{readings}");
|
||||
assert!(readings.contains("A worked instance of T 1."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untiered_lecture_is_grouped_by_level_as_before() {
|
||||
// Where no targets are declared, each objective is its own target and
|
||||
// there is nothing to nest, so the page keeps its level headings.
|
||||
let md = objectives_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
|
||||
assert!(md.contains("objective lo-first"), "{md}");
|
||||
assert!(md.contains("objective lo-second"), "{md}");
|
||||
assert!(
|
||||
!md.contains("**"),
|
||||
"nothing to bold on an untiered page: {md}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_objective_with_no_level_still_appears() {
|
||||
// Ungrouped, at the end, rather than silently dropped.
|
||||
|
||||
+36
-38
@@ -30,7 +30,7 @@
|
||||
|
||||
use crate::assessment::{AssessmentFile, Form, Placement};
|
||||
use crate::catalog::Catalog;
|
||||
use crate::course::{CourseFile, Reference};
|
||||
use crate::course::CourseFile;
|
||||
use crate::error::Result;
|
||||
use crate::item::{Choice, Citation, Item};
|
||||
use crate::markup;
|
||||
@@ -223,7 +223,7 @@ pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Res
|
||||
let mut printed_stimulus: Option<String> = None;
|
||||
|
||||
let layout = select::layout(record, &opts.form);
|
||||
for (position, placement) in layout.iter().filter(|p| !p.dropped).enumerate() {
|
||||
for (position, placement) in layout.iter().filter(|p| p.was_printed()).enumerate() {
|
||||
let entry = catalog.require(&placement.item)?;
|
||||
let item = &entry.item;
|
||||
let number = position + 1;
|
||||
@@ -259,11 +259,10 @@ pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Res
|
||||
|
||||
/// The Quarto YAML front matter.
|
||||
fn front_matter(course: &CourseFile, record: &AssessmentFile, variant: Variant) -> String {
|
||||
let title = if matches!(variant, Variant::Solutions) {
|
||||
format!("{}: {}", record.assessment.title, variant.title_word())
|
||||
} else {
|
||||
record.assessment.title.clone()
|
||||
};
|
||||
// Both documents name themselves. Two PDFs called "Homework 1" are
|
||||
// indistinguishable in a downloads folder, which is how a solutions copy
|
||||
// gets posted in place of the worksheet.
|
||||
let title = format!("{}: {}", record.assessment.title, variant.title_word());
|
||||
let subtitle = format!("{} · {}", course.course.code, course.course.title);
|
||||
let mut out = String::from("---\n");
|
||||
out.push_str(&format!("title: \"{}\"\n", yaml_quote(&title)));
|
||||
@@ -290,7 +289,7 @@ fn worksheet_question(
|
||||
out.push_str("\n\n");
|
||||
|
||||
if item.has_options() {
|
||||
let ordered = ordered_options(item, form, &placement.item);
|
||||
let ordered = ordered_options(item, placement, form);
|
||||
for (position, source) in ordered.iter().enumerate() {
|
||||
out.push_str(&format!(
|
||||
"{}. {}\n",
|
||||
@@ -321,7 +320,7 @@ fn solution_question(
|
||||
out.push_str("\n\n");
|
||||
|
||||
if item.has_options() {
|
||||
let ordered = ordered_options(item, form, &placement.item);
|
||||
let ordered = ordered_options(item, placement, form);
|
||||
for (position, source) in ordered.iter().enumerate() {
|
||||
let mark = if source.correct { " ✓" } else { "" };
|
||||
let note = source
|
||||
@@ -390,13 +389,13 @@ fn solution_body(out: &mut String, item: &Item) {
|
||||
|
||||
/// The `Tests:` line naming the objectives this item measures.
|
||||
fn objectives_line(out: &mut String, item: &Item, course: &CourseFile) {
|
||||
if item.learning_objectives.is_empty() {
|
||||
if item.learning_targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
let texts: Vec<String> = item
|
||||
.learning_objectives
|
||||
.learning_targets
|
||||
.iter()
|
||||
.map(|id| course.objective_text(id))
|
||||
.map(|id| course.text_for(id))
|
||||
.collect();
|
||||
out.push_str(&format!("**Tests:** {}\n\n", texts.join("; ")));
|
||||
}
|
||||
@@ -428,9 +427,9 @@ fn cite(course: &CourseFile, citation: &Citation) -> String {
|
||||
let Some(reference) = course.references.get(key) else {
|
||||
return citation.display();
|
||||
};
|
||||
let label = reference.label.as_deref().unwrap_or(key);
|
||||
let label = reference.label_or(key);
|
||||
let locator = citation.locator.as_deref().unwrap_or("");
|
||||
match resolve_url(citation, reference) {
|
||||
match citation.href(reference) {
|
||||
Some(url) if !locator.is_empty() => format!("`{label}` [{locator}]({url})"),
|
||||
Some(url) => format!("`{label}` [{}]({url})", reference.title),
|
||||
None if !locator.is_empty() => format!("`{label}` {locator}"),
|
||||
@@ -438,21 +437,6 @@ fn cite(course: &CourseFile, citation: &Citation) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL for a citation: its own `url`, else the reference `base_url` joined with
|
||||
/// the citation `path`.
|
||||
fn resolve_url(citation: &Citation, reference: &Reference) -> Option<String> {
|
||||
if let Some(url) = &citation.url {
|
||||
return Some(url.clone());
|
||||
}
|
||||
let path = citation.path.as_deref()?;
|
||||
let base = reference.base_url.as_deref()?;
|
||||
Some(match (base.ends_with('/'), path.starts_with('/')) {
|
||||
(true, true) => format!("{base}{}", &path[1..]),
|
||||
(false, false) => format!("{base}/{path}"),
|
||||
_ => format!("{base}{path}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// The `## Question N` heading, marking a bonus item.
|
||||
fn heading(number: usize, placement: &Placement) -> String {
|
||||
let bonus = if placement.bonus { " (bonus)" } else { "" };
|
||||
@@ -474,10 +458,11 @@ fn meta_line(placement: &Placement, item: &Item) -> String {
|
||||
/// Salted with the item's global id, the same value the Typst and QTI exports use,
|
||||
/// so a worksheet built for form B lists options in the order that form's paper and
|
||||
/// its Canvas quiz do.
|
||||
fn ordered_options<'a>(item: &'a Item, form: &Form, uid: &str) -> Vec<&'a Choice> {
|
||||
select::option_order(form, uid, item.options.len())
|
||||
fn ordered_options<'a>(item: &'a Item, placement: &Placement, form: &Form) -> Vec<&'a Choice> {
|
||||
let shown = item.administered(&placement.key, &placement.distractors);
|
||||
select::option_order(form, &placement.item, shown.len())
|
||||
.into_iter()
|
||||
.map(|i| &item.options[i])
|
||||
.map(|i| shown[i])
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -542,7 +527,7 @@ items:
|
||||
status: draft
|
||||
level: 1
|
||||
stem: At constant pressure, the heat exchanged equals which quantity?
|
||||
learning_objectives: [lo-enthalpy]
|
||||
learning_targets: [lo-enthalpy]
|
||||
options:
|
||||
- { id: A, text: "the enthalpy change", correct: true, feedback_student: "Right: P dV work is folded into H." }
|
||||
- { id: B, text: "the internal energy change", misconception: "ignores expansion work" }
|
||||
@@ -556,7 +541,7 @@ items:
|
||||
level: 2
|
||||
format: open_response
|
||||
stem: Explain why, at constant pressure, the heat exchanged equals the enthalpy change.
|
||||
learning_objectives: [lo-enthalpy]
|
||||
learning_targets: [lo-enthalpy]
|
||||
solution:
|
||||
model_answer: "At constant pressure the P dV expansion work is folded into H = U + PV, so q_p is the change in H."
|
||||
rubric:
|
||||
@@ -594,29 +579,37 @@ items:
|
||||
number: 1,
|
||||
item: "l11::q-enthalpy-001".into(),
|
||||
version: None,
|
||||
stem_digest: None,
|
||||
distractors: Vec::new(),
|
||||
variant: None,
|
||||
fingerprint: None,
|
||||
points: Some(1.0),
|
||||
bonus: false,
|
||||
key: vec!["A".into()],
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
learning_targets: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: false,
|
||||
dropped_as: None,
|
||||
dropped_before_printing: false,
|
||||
},
|
||||
Placement {
|
||||
number: 2,
|
||||
item: "l11::q-enthalpy-op-001".into(),
|
||||
version: None,
|
||||
stem_digest: None,
|
||||
distractors: Vec::new(),
|
||||
variant: None,
|
||||
fingerprint: None,
|
||||
points: Some(2.0),
|
||||
bonus: false,
|
||||
key: Vec::new(),
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
learning_targets: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: false,
|
||||
dropped_as: None,
|
||||
dropped_before_printing: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -663,13 +656,18 @@ items:
|
||||
&Options::default().form,
|
||||
)
|
||||
.expect("renders");
|
||||
assert!(ws.contains("title: \"Homework 1: Questions\""));
|
||||
assert!(ws.contains("title: \"Homework 1: Questions\""), "{ws}");
|
||||
let sol = solutions(
|
||||
&catalog("front-matter-sol"),
|
||||
&record(),
|
||||
&Options::default().form,
|
||||
)
|
||||
.expect("renders");
|
||||
assert!(sol.contains("title: \"Homework 1: Solutions\""));
|
||||
assert!(sol.contains("title: \"Homework 1: Solutions\""), "{sol}");
|
||||
// Neither document may title itself with the bare assessment name: two
|
||||
// PDFs called "Homework 1" are indistinguishable once downloaded, and
|
||||
// the pair that gets confused is the one with the answers in it.
|
||||
assert!(!ws.contains("title: \"Homework 1\""), "{ws}");
|
||||
assert!(!sol.contains("title: \"Homework 1\""), "{sol}");
|
||||
}
|
||||
}
|
||||
|
||||
+46
-8
@@ -476,10 +476,12 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> R
|
||||
if !placement.bonus {
|
||||
total_points += points;
|
||||
}
|
||||
let shown = item.administered(&placement.key, &placement.distractors);
|
||||
items.push(build_item(
|
||||
&record.assessment.id,
|
||||
&placement.item,
|
||||
item,
|
||||
&shown,
|
||||
points,
|
||||
opts,
|
||||
));
|
||||
@@ -564,14 +566,21 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> R
|
||||
/// # Returns
|
||||
///
|
||||
/// The element.
|
||||
fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &QtiOptions) -> Node {
|
||||
fn build_item(
|
||||
assessment_id: &str,
|
||||
uid: &str,
|
||||
item: &Item,
|
||||
shown: &[&crate::item::Choice],
|
||||
points: f64,
|
||||
opts: &QtiOptions,
|
||||
) -> Node {
|
||||
// An open-response item is an essay in Canvas: no choices, graded by hand.
|
||||
if !item.format.has_options() {
|
||||
return build_essay_item(assessment_id, uid, item, points, opts);
|
||||
}
|
||||
|
||||
let order = select::option_order(&opts.form, uid, item.options.len());
|
||||
let ordered: Vec<&crate::item::Choice> = order.iter().map(|i| &item.options[*i]).collect();
|
||||
let order = select::option_order(&opts.form, uid, shown.len());
|
||||
let ordered: Vec<&crate::item::Choice> = order.iter().map(|i| shown[*i]).collect();
|
||||
|
||||
// Option identifiers are numeric, mirroring Canvas's own exports, and are
|
||||
// derived from the item id so they survive regeneration.
|
||||
@@ -1172,7 +1181,11 @@ mod tests {
|
||||
assert!(m.contains("<allowed_attempts>-1</allowed_attempts>"));
|
||||
assert!(m.contains("<quiz_type>assignment</quiz_type>"));
|
||||
assert!(m.contains("<show_correct_answers>true</show_correct_answers>"));
|
||||
assert!(m.contains("<shuffle_answers>false</shuffle_answers>"));
|
||||
// Canvas does the shuffling. The form fixes an option order for the
|
||||
// printed paper, but an online quiz has no printed order to preserve and
|
||||
// `data::canvas` matches responses back by answer text rather than by
|
||||
// letter, so a per-student shuffle costs the analysis nothing.
|
||||
assert!(m.contains("<shuffle_answers>true</shuffle_answers>"));
|
||||
// Default is unpublished, so an import never goes live unreviewed.
|
||||
assert!(m.contains("<available>false</available>"));
|
||||
assert!(m.contains("<workflow_state>unpublished</workflow_state>"));
|
||||
@@ -1180,6 +1193,22 @@ mod tests {
|
||||
assert!(m.contains(CANVAS_NS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shuffling_can_be_turned_off_per_assessment() {
|
||||
// A record with `shuffle: false` is the case where the printed order has
|
||||
// to be preserved online too, such as an item whose options are a
|
||||
// sequence ("first ... then ...") that reads wrong in any other order.
|
||||
let opts = QtiOptions {
|
||||
shuffle_in_canvas: false,
|
||||
..QtiOptions::default()
|
||||
};
|
||||
let m = build_assessment_meta("gq", "ga", "T", 1.0, &opts).render(0);
|
||||
assert!(
|
||||
m.contains("<shuffle_answers>false</shuffle_answers>"),
|
||||
"{m}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_results_tracks_letting_students_see_responses() {
|
||||
// Default (on): hide_results is empty, so students may review and the
|
||||
@@ -1239,6 +1268,7 @@ mod tests {
|
||||
defense: None,
|
||||
feedback_student: None,
|
||||
selection_rate_expected: None,
|
||||
retired: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1326,7 +1356,7 @@ items:
|
||||
level: 2
|
||||
format: single_best_answer
|
||||
stem: "The heat at constant pressure equals a change in what?"
|
||||
learning_objectives: [lo-enthalpy]
|
||||
learning_targets: [lo-enthalpy]
|
||||
options:
|
||||
- { id: A, text: "Enthalpy, $\\Delta H$", correct: true }
|
||||
- { id: B, text: "Internal energy, $\\Delta U$", misconception: "Constant-volume result." }
|
||||
@@ -1335,7 +1365,7 @@ items:
|
||||
level: 3
|
||||
format: open_response
|
||||
stem: "Show why $q_p = \\Delta H$."
|
||||
learning_objectives: [lo-enthalpy]
|
||||
learning_targets: [lo-enthalpy]
|
||||
solution:
|
||||
model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$."
|
||||
"#,
|
||||
@@ -1369,29 +1399,37 @@ items:
|
||||
number: 1,
|
||||
item: "b::q-mcq".into(),
|
||||
version: None,
|
||||
stem_digest: None,
|
||||
distractors: Vec::new(),
|
||||
variant: None,
|
||||
fingerprint: None,
|
||||
points: Some(1.0),
|
||||
bonus: false,
|
||||
key: vec!["A".into()],
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
learning_targets: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: false,
|
||||
dropped_as: None,
|
||||
dropped_before_printing: false,
|
||||
},
|
||||
Placement {
|
||||
number: 2,
|
||||
item: "b::q-open".into(),
|
||||
version: None,
|
||||
stem_digest: None,
|
||||
distractors: Vec::new(),
|
||||
variant: None,
|
||||
fingerprint: None,
|
||||
points: Some(2.0),
|
||||
bonus: false,
|
||||
key: Vec::new(),
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
learning_targets: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: false,
|
||||
dropped_as: None,
|
||||
dropped_before_printing: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! The bibliography, in the formats other tools read.
|
||||
//!
|
||||
//! `references.yaml` is the authoritative copy, and it is shaped for a reading
|
||||
//! list: it carries a `label` that reports print, and a `role` saying whether
|
||||
//! the course requires the work or offers it as background. No general citation
|
||||
//! format has either field, which is why the course keeps its own.
|
||||
//!
|
||||
//! What the other formats are for is everything downstream of the reading list:
|
||||
//!
|
||||
//! | Format | Read by | Gets you |
|
||||
//! |:--|:--|:--|
|
||||
//! | [`Format::Hayagriva`] | Typst | real citations in a printed exam or report |
|
||||
//! | [`Format::CslJson`] | Zotero, Pandoc, CSL processors | a bibliography in any style |
|
||||
//! | [`Format::Bibtex`] | LaTeX, most reference managers | the lowest common denominator |
|
||||
//!
|
||||
//! All three are generated. The argument is the same one the lecture reading
|
||||
//! list makes: two copies of a citation drift within a term, and one copy plus a
|
||||
//! build step does not.
|
||||
//!
|
||||
//! # What does not survive the trip
|
||||
//!
|
||||
//! `label`, `role`, `base_url`, and `note` have nowhere to go in any of the
|
||||
//! three, so they stay behind. That is the reason this is an export rather than
|
||||
//! a migration: the course file is not recoverable from its own bibliography
|
||||
//! export, and nothing reads these files back in.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::course::{CourseFile, Reference, ReferenceKind};
|
||||
use crate::error::Result;
|
||||
use crate::yaml;
|
||||
|
||||
/// Which citation format to write.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
/// Hayagriva YAML, which Typst's `#bibliography` reads natively.
|
||||
Hayagriva,
|
||||
/// CSL-JSON, which Zotero, Pandoc, and every CSL processor read.
|
||||
CslJson,
|
||||
/// BibTeX, for LaTeX and for reference managers that read nothing else.
|
||||
Bibtex,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
/// The conventional file extension.
|
||||
pub fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Format::Hayagriva => "yml",
|
||||
Format::CslJson => "json",
|
||||
Format::Bibtex => "bib",
|
||||
}
|
||||
}
|
||||
|
||||
/// The name used on the command line.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Format::Hayagriva => "hayagriva",
|
||||
Format::CslJson => "csl-json",
|
||||
Format::Bibtex => "bibtex",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a course's bibliography.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `course` - the loaded course.
|
||||
/// * `format` - which format to write.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The document, keyed or ordered by citation key so the output is stable
|
||||
/// between runs.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::error::Error::Other`] if the intermediate structure cannot
|
||||
/// be serialized.
|
||||
pub fn render(course: &CourseFile, format: Format) -> Result<String> {
|
||||
match format {
|
||||
Format::Hayagriva => hayagriva(course),
|
||||
Format::CslJson => csl_json(course),
|
||||
Format::Bibtex => Ok(bibtex(course)),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hayagriva ---
|
||||
|
||||
/// One Hayagriva entry.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Entry {
|
||||
#[serde(rename = "type")]
|
||||
kind: &'static str,
|
||||
title: String,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
author: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
date: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
edition: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
publisher: Option<String>,
|
||||
#[serde(rename = "page-range", skip_serializing_if = "Option::is_none")]
|
||||
page_range: Option<String>,
|
||||
#[serde(rename = "serial-number", skip_serializing_if = "BTreeMap::is_empty")]
|
||||
serial_number: BTreeMap<&'static str, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
parent: Option<Parent>,
|
||||
}
|
||||
|
||||
/// The container a Hayagriva entry sits inside.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Parent {
|
||||
#[serde(rename = "type")]
|
||||
kind: &'static str,
|
||||
title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
volume: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
issue: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
publisher: Option<String>,
|
||||
}
|
||||
|
||||
/// Renders the bibliography as Hayagriva YAML.
|
||||
fn hayagriva(course: &CourseFile) -> Result<String> {
|
||||
let entries: BTreeMap<&String, Entry> = course
|
||||
.references
|
||||
.iter()
|
||||
.map(|(key, reference)| (key, entry(reference)))
|
||||
.collect();
|
||||
yaml::to_string(&entries)
|
||||
}
|
||||
|
||||
/// Maps one reference onto a Hayagriva entry.
|
||||
fn entry(reference: &Reference) -> Entry {
|
||||
let mut serial: BTreeMap<&'static str, String> = BTreeMap::new();
|
||||
for (field, value) in [
|
||||
("doi", &reference.doi),
|
||||
("isbn", &reference.isbn),
|
||||
("arxiv", &reference.arxiv),
|
||||
("pmid", &reference.pmid),
|
||||
("pmcid", &reference.pmcid),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
serial.insert(field, value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// A chapter sits in a book and an article sits in a periodical, and
|
||||
// Hayagriva wants that said with a parent rather than a flat field.
|
||||
let parent = reference
|
||||
.container
|
||||
.as_ref()
|
||||
.map(|title| match reference.kind {
|
||||
ReferenceKind::Chapter => Parent {
|
||||
kind: "book",
|
||||
title: title.clone(),
|
||||
volume: reference.volume.clone(),
|
||||
issue: None,
|
||||
publisher: reference.publisher.clone(),
|
||||
},
|
||||
_ => Parent {
|
||||
kind: "periodical",
|
||||
title: title.clone(),
|
||||
volume: reference.volume.clone(),
|
||||
issue: reference.issue.clone(),
|
||||
publisher: None,
|
||||
},
|
||||
});
|
||||
|
||||
Entry {
|
||||
kind: hayagriva_kind(reference.kind),
|
||||
title: reference.title.clone(),
|
||||
author: reference.authors.clone(),
|
||||
date: reference.year,
|
||||
edition: reference.edition.clone(),
|
||||
// The publisher belongs to the container when there is one.
|
||||
publisher: if parent.is_some() {
|
||||
None
|
||||
} else {
|
||||
reference.publisher.clone()
|
||||
},
|
||||
page_range: reference.pages.clone(),
|
||||
serial_number: serial,
|
||||
url: reference.url.clone(),
|
||||
parent,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Hayagriva entry type for a course reference kind.
|
||||
fn hayagriva_kind(kind: ReferenceKind) -> &'static str {
|
||||
match kind {
|
||||
ReferenceKind::Book => "book",
|
||||
ReferenceKind::Chapter => "chapter",
|
||||
// Hayagriva has no preprint type. An article with no periodical parent
|
||||
// is what a preprint is anyway.
|
||||
ReferenceKind::Article | ReferenceKind::Preprint => "article",
|
||||
ReferenceKind::Thesis => "thesis",
|
||||
ReferenceKind::Website => "web",
|
||||
ReferenceKind::Software | ReferenceKind::Dataset => "repository",
|
||||
ReferenceKind::Video => "video",
|
||||
ReferenceKind::Other => "misc",
|
||||
}
|
||||
}
|
||||
|
||||
// --- CSL-JSON ---
|
||||
|
||||
/// Renders the bibliography as CSL-JSON.
|
||||
fn csl_json(course: &CourseFile) -> Result<String> {
|
||||
let items: Vec<Value> = course
|
||||
.references
|
||||
.iter()
|
||||
.map(|(key, reference)| csl_item(key, reference))
|
||||
.collect();
|
||||
serde_json::to_string_pretty(&items)
|
||||
.map(|text| format!("{text}\n"))
|
||||
.map_err(crate::error::Error::other)
|
||||
}
|
||||
|
||||
/// One CSL-JSON item.
|
||||
fn csl_item(key: &str, reference: &Reference) -> Value {
|
||||
let mut item = json!({
|
||||
"id": key,
|
||||
"type": csl_kind(reference.kind),
|
||||
"title": reference.title,
|
||||
});
|
||||
let map = item.as_object_mut().expect("built from a JSON object");
|
||||
|
||||
if !reference.authors.is_empty() {
|
||||
map.insert(
|
||||
"author".to_string(),
|
||||
Value::Array(reference.authors.iter().map(|a| csl_name(a)).collect()),
|
||||
);
|
||||
}
|
||||
if let Some(year) = reference.year {
|
||||
map.insert("issued".to_string(), json!({ "date-parts": [[year]] }));
|
||||
}
|
||||
for (field, value) in [
|
||||
("container-title", &reference.container),
|
||||
("publisher", &reference.publisher),
|
||||
("volume", &reference.volume),
|
||||
("issue", &reference.issue),
|
||||
("page", &reference.pages),
|
||||
("edition", &reference.edition),
|
||||
("DOI", &reference.doi),
|
||||
("ISBN", &reference.isbn),
|
||||
("PMID", &reference.pmid),
|
||||
("PMCID", &reference.pmcid),
|
||||
("URL", &reference.url),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
map.insert(field.to_string(), Value::String(value.clone()));
|
||||
}
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
/// Splits `Family, Given` into a CSL name, or keeps it whole.
|
||||
///
|
||||
/// A name with no comma is not a name this code can take apart — an
|
||||
/// organization, or a single mononym — so it goes in `literal`, which is what
|
||||
/// CSL has the field for.
|
||||
fn csl_name(author: &str) -> Value {
|
||||
match author.split_once(',') {
|
||||
Some((family, given)) => json!({
|
||||
"family": family.trim(),
|
||||
"given": given.trim(),
|
||||
}),
|
||||
None => json!({ "literal": author.trim() }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The CSL type for a course reference kind.
|
||||
fn csl_kind(kind: ReferenceKind) -> &'static str {
|
||||
match kind {
|
||||
ReferenceKind::Book => "book",
|
||||
ReferenceKind::Chapter => "chapter",
|
||||
ReferenceKind::Article => "article-journal",
|
||||
ReferenceKind::Preprint => "article",
|
||||
ReferenceKind::Thesis => "thesis",
|
||||
ReferenceKind::Website => "webpage",
|
||||
ReferenceKind::Software => "software",
|
||||
ReferenceKind::Dataset => "dataset",
|
||||
ReferenceKind::Video => "motion_picture",
|
||||
ReferenceKind::Other => "document",
|
||||
}
|
||||
}
|
||||
|
||||
// --- BibTeX ---
|
||||
|
||||
/// Renders the bibliography as BibTeX.
|
||||
fn bibtex(course: &CourseFile) -> String {
|
||||
let mut out = String::new();
|
||||
for (key, reference) in &course.references {
|
||||
out.push_str(&bibtex_entry(key, reference));
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One BibTeX entry.
|
||||
fn bibtex_entry(key: &str, reference: &Reference) -> String {
|
||||
let mut fields: Vec<(&str, String)> = vec![("title", reference.title.clone())];
|
||||
if !reference.authors.is_empty() {
|
||||
fields.push(("author", reference.authors.join(" and ")));
|
||||
}
|
||||
if let Some(year) = reference.year {
|
||||
fields.push(("year", year.to_string()));
|
||||
}
|
||||
if let Some(container) = &reference.container {
|
||||
let field = match reference.kind {
|
||||
ReferenceKind::Chapter => "booktitle",
|
||||
_ => "journal",
|
||||
};
|
||||
fields.push((field, container.clone()));
|
||||
}
|
||||
for (field, value) in [
|
||||
("volume", &reference.volume),
|
||||
("number", &reference.issue),
|
||||
("edition", &reference.edition),
|
||||
("publisher", &reference.publisher),
|
||||
("doi", &reference.doi),
|
||||
("isbn", &reference.isbn),
|
||||
("url", &reference.url),
|
||||
("note", &reference.note),
|
||||
] {
|
||||
if let Some(value) = value {
|
||||
fields.push((field, value.clone()));
|
||||
}
|
||||
}
|
||||
if let Some(pages) = &reference.pages {
|
||||
fields.push(("pages", en_dash(pages)));
|
||||
}
|
||||
|
||||
let body: String = fields
|
||||
.iter()
|
||||
.map(|(field, value)| format!(" {field} = {{{}}},\n", escape_tex(value)))
|
||||
.collect();
|
||||
format!("@{}{{{key},\n{body}}}\n", bibtex_kind(reference.kind))
|
||||
}
|
||||
|
||||
/// The BibTeX entry type for a course reference kind.
|
||||
fn bibtex_kind(kind: ReferenceKind) -> &'static str {
|
||||
match kind {
|
||||
ReferenceKind::Book => "book",
|
||||
ReferenceKind::Chapter => "incollection",
|
||||
ReferenceKind::Article => "article",
|
||||
ReferenceKind::Thesis => "phdthesis",
|
||||
ReferenceKind::Website => "online",
|
||||
// BibTeX proper has nothing for these. `misc` with a `note` is what
|
||||
// every style guide says to do, and biblatex users can convert.
|
||||
ReferenceKind::Preprint
|
||||
| ReferenceKind::Software
|
||||
| ReferenceKind::Dataset
|
||||
| ReferenceKind::Video
|
||||
| ReferenceKind::Other => "misc",
|
||||
}
|
||||
}
|
||||
|
||||
/// Escapes the characters BibTeX treats as syntax.
|
||||
///
|
||||
/// Deliberately short: a publisher called `John Wiley & Sons` is the case that
|
||||
/// actually occurs, and escaping more than this risks mangling the `$...$` in a
|
||||
/// title that carries real mathematics.
|
||||
fn escape_tex(value: &str) -> String {
|
||||
value.replace('&', "\\&").replace('%', "\\%")
|
||||
}
|
||||
|
||||
/// Turns a hyphenated page range into the en dash BibTeX expects.
|
||||
fn en_dash(pages: &str) -> String {
|
||||
let parts: Vec<&str> = pages.split('-').collect();
|
||||
if parts.len() == 2 && parts.iter().all(|p| !p.is_empty()) {
|
||||
return format!("{}--{}", parts[0].trim(), parts[1].trim());
|
||||
}
|
||||
pages.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::course::ReferenceRole;
|
||||
|
||||
fn course() -> CourseFile {
|
||||
let mut c = CourseFile::skeleton("BIOSC 1540", "Computational Biology", "2026f");
|
||||
c.references.insert(
|
||||
"ismail2023bioinformatics".into(),
|
||||
Reference {
|
||||
label: Some("IBB".into()),
|
||||
kind: ReferenceKind::Book,
|
||||
role: ReferenceRole::Supplemental,
|
||||
title: "Bioinformatics: A practical guide".into(),
|
||||
authors: vec!["Ismail, H. D.".into()],
|
||||
year: Some(2023),
|
||||
publisher: Some("CRC Press".into()),
|
||||
base_url: Some("https://library.example.org/ismail2023/".into()),
|
||||
isbn: Some("9781032366423".into()),
|
||||
..Reference::default()
|
||||
},
|
||||
);
|
||||
c.references.insert(
|
||||
"altschul1990basic".into(),
|
||||
Reference {
|
||||
label: Some("BLAST".into()),
|
||||
kind: ReferenceKind::Article,
|
||||
role: ReferenceRole::Required,
|
||||
title: "Basic local alignment search tool".into(),
|
||||
authors: vec![
|
||||
"Altschul, S. F.".into(),
|
||||
"Gish, W.".into(),
|
||||
"Wiley & Sons".into(),
|
||||
],
|
||||
year: Some(1990),
|
||||
container: Some("Journal of Molecular Biology".into()),
|
||||
volume: Some("215".into()),
|
||||
issue: Some("3".into()),
|
||||
pages: Some("403-410".into()),
|
||||
doi: Some("10.1016/S0022-2836(05)80360-2".into()),
|
||||
pmid: Some("2231712".into()),
|
||||
..Reference::default()
|
||||
},
|
||||
);
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hayagriva_nests_an_article_under_its_periodical() {
|
||||
let out = render(&course(), Format::Hayagriva).unwrap();
|
||||
assert!(out.contains("altschul1990basic:"), "{out}");
|
||||
assert!(out.contains("type: article"), "{out}");
|
||||
assert!(out.contains("type: periodical"), "{out}");
|
||||
assert!(out.contains("Journal of Molecular Biology"), "{out}");
|
||||
assert!(out.contains("page-range: 403-410"), "{out}");
|
||||
assert!(out.contains("doi: 10.1016/S0022-2836(05)80360-2"), "{out}");
|
||||
// Parses back as YAML, which is what Typst will do to it.
|
||||
let back: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap();
|
||||
assert!(back.get("ismail2023bioinformatics").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hayagriva_keeps_a_books_publisher_on_the_book() {
|
||||
let out = render(&course(), Format::Hayagriva).unwrap();
|
||||
let parsed: serde_yaml_ng::Value = serde_yaml_ng::from_str(&out).unwrap();
|
||||
let book = parsed.get("ismail2023bioinformatics").unwrap();
|
||||
assert_eq!(book.get("publisher").unwrap().as_str(), Some("CRC Press"));
|
||||
assert!(book.get("parent").is_none());
|
||||
// `label`, `role`, and `base_url` have nowhere to go and stay behind.
|
||||
assert!(book.get("label").is_none());
|
||||
assert!(book.get("base_url").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csl_json_splits_names_and_keeps_organizations_whole() {
|
||||
let out = render(&course(), Format::CslJson).unwrap();
|
||||
let items: Vec<Value> = serde_json::from_str(&out).unwrap();
|
||||
let article = items
|
||||
.iter()
|
||||
.find(|i| i["id"] == "altschul1990basic")
|
||||
.unwrap();
|
||||
assert_eq!(article["type"], "article-journal");
|
||||
assert_eq!(article["author"][0]["family"], "Altschul");
|
||||
assert_eq!(article["author"][0]["given"], "S. F.");
|
||||
assert_eq!(article["author"][2]["literal"], "Wiley & Sons");
|
||||
assert_eq!(article["issued"]["date-parts"][0][0], 1990);
|
||||
assert_eq!(article["DOI"], "10.1016/S0022-2836(05)80360-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bibtex_escapes_ampersands_and_dashes_a_page_range() {
|
||||
let out = render(&course(), Format::Bibtex).unwrap();
|
||||
assert!(out.contains("@article{altschul1990basic,"), "{out}");
|
||||
assert!(
|
||||
out.contains("journal = {Journal of Molecular Biology},"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("pages = {403--410},"), "{out}");
|
||||
assert!(out.contains("Wiley \\& Sons"), "{out}");
|
||||
assert!(out.contains("@book{ismail2023bioinformatics,"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_course_with_no_bibliography_renders_empty_rather_than_failing() {
|
||||
let mut c = course();
|
||||
c.references.clear();
|
||||
assert_eq!(render(&c, Format::Bibtex).unwrap(), "");
|
||||
assert_eq!(render(&c, Format::CslJson).unwrap().trim(), "[]");
|
||||
}
|
||||
}
|
||||
+66
-20
@@ -36,7 +36,7 @@ use crate::course::CourseFile;
|
||||
use crate::date::Date;
|
||||
use crate::irt::Fit;
|
||||
use crate::students::{Cohort, Mastery, StudentSummary};
|
||||
use crate::taxonomy::Level;
|
||||
use crate::taxonomy::{Level, Tier};
|
||||
|
||||
/// What to include in a student report.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -145,16 +145,37 @@ pub fn student(
|
||||
}
|
||||
|
||||
// --- objectives
|
||||
if opts.objectives && !summary.objectives.is_empty() {
|
||||
//
|
||||
// Objectives only. A target row would rest on one question and could only
|
||||
// ever read "not enough questions to say", so a mixed table buried the few
|
||||
// real classifications among dozens of non-statements. The targets appear
|
||||
// where they can be acted on instead: under each lecture in "what to
|
||||
// revise", and beside each missed question.
|
||||
if opts.objectives && summary.objectives.iter().any(|o| o.tier == Tier::Objective) {
|
||||
out.push_str("## What this exam says about each learning objective\n\n");
|
||||
out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n");
|
||||
for o in &summary.objectives {
|
||||
let you = format!("{:.0}%", o.rate * 100.0);
|
||||
for o in summary
|
||||
.objectives
|
||||
.iter()
|
||||
.filter(|o| o.tier == Tier::Objective)
|
||||
{
|
||||
// How much of the objective this exam reached, which is the scope of
|
||||
// the claim the row makes.
|
||||
let scope = if o.targets_total > 0 {
|
||||
format!(
|
||||
"{} ({} of {} targets tested)",
|
||||
escape_pipes(&o.text),
|
||||
o.targets_seen,
|
||||
o.targets_total
|
||||
)
|
||||
} else {
|
||||
escape_pipes(&o.text)
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"| {} | {} | {} | {:.0}% | {} |\n",
|
||||
"| {} | {} | {:.0}% | {:.0}% | {} |\n",
|
||||
o.status.symbol(),
|
||||
escape_pipes(&o.text),
|
||||
you,
|
||||
scope,
|
||||
o.rate * 100.0,
|
||||
o.cohort_rate * 100.0,
|
||||
o.n_items
|
||||
));
|
||||
@@ -167,7 +188,7 @@ pub fn student(
|
||||
let thin: Vec<&str> = summary
|
||||
.objectives
|
||||
.iter()
|
||||
.filter(|o| o.status == Mastery::NotEnoughEvidence)
|
||||
.filter(|o| o.tier == Tier::Objective && o.status == Mastery::NotEnoughEvidence)
|
||||
.map(|o| o.text.as_str())
|
||||
.collect();
|
||||
if !thin.is_empty() {
|
||||
@@ -236,7 +257,7 @@ pub fn student(
|
||||
out.push_str("## Where to put your time\n\n");
|
||||
out.push_str("In this order:\n\n");
|
||||
for (i, id) in summary.focus.iter().take(4).enumerate() {
|
||||
let text = course.objective_text(id);
|
||||
let text = course.text_for(id);
|
||||
out.push_str(&format!("{}. {}\n", i + 1, text));
|
||||
}
|
||||
out.push('\n');
|
||||
@@ -250,10 +271,7 @@ pub fn student(
|
||||
.map(|(id, _)| id.as_str())
|
||||
.collect();
|
||||
if !class_gaps.is_empty() {
|
||||
let texts: Vec<String> = class_gaps
|
||||
.iter()
|
||||
.map(|id| course.objective_text(id))
|
||||
.collect();
|
||||
let texts: Vec<String> = class_gaps.iter().map(|id| course.text_for(id)).collect();
|
||||
let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
|
||||
out.push_str(&format!(
|
||||
"Most of the class also struggled with {}, so expect it to come back in class. \
|
||||
@@ -268,7 +286,7 @@ pub fn student(
|
||||
.strengths
|
||||
.iter()
|
||||
.take(4)
|
||||
.map(|id| course.objective_text(id))
|
||||
.map(|id| course.text_for(id))
|
||||
.collect();
|
||||
let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
|
||||
out.push_str(&format!("You have clearly got {}.\n\n", list(&refs)));
|
||||
@@ -288,6 +306,20 @@ pub fn student(
|
||||
String::new()
|
||||
};
|
||||
out.push_str(&format!("**Question {}**{partial}\n\n", m.number));
|
||||
// Both tiers: the target says what this question asked, the
|
||||
// objective says which row of the table above it counted toward.
|
||||
for target in &m.learning_targets {
|
||||
let objective = course.objective_for(target);
|
||||
if objective == target.as_str() {
|
||||
out.push_str(&format!("Asked you to: {}\n\n", course.text_for(target)));
|
||||
} else {
|
||||
out.push_str(&format!(
|
||||
"Asked you to: {} \nCounts toward: {}\n\n",
|
||||
course.text_for(target),
|
||||
course.text_for(objective)
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(text) = &m.feedback {
|
||||
out.push_str(&format!("{text}\n\n"));
|
||||
} else if let Some(misconception) = &m.misconception {
|
||||
@@ -549,7 +581,7 @@ pub fn cohort(
|
||||
for (id, rate) in &cohort.class_gaps {
|
||||
out.push_str(&format!(
|
||||
"| {} | {:.0}% |\n",
|
||||
escape_pipes(&course.objective_text(id)),
|
||||
escape_pipes(&course.text_for(id)),
|
||||
rate * 100.0
|
||||
));
|
||||
}
|
||||
@@ -576,7 +608,7 @@ pub fn cohort(
|
||||
out.push('\n');
|
||||
|
||||
if let Some(bp) = &record.blueprint {
|
||||
let drift = crate::select::check_blueprint(record);
|
||||
let drift = crate::select::check_blueprint(record, course);
|
||||
if !drift.is_empty() {
|
||||
out.push_str("Blueprint drift:\n\n");
|
||||
for d in &drift {
|
||||
@@ -956,6 +988,10 @@ pub fn write_all_students(
|
||||
/// Per-objective class rates as a compact table, for pasting into a syllabus
|
||||
/// review or a curriculum committee document.
|
||||
///
|
||||
/// Objectives only. A committee document listing three hundred learning targets
|
||||
/// is not read, and the target rates are the wrong number to put in front of one
|
||||
/// anyway: each rests on one or two questions.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `cohort` - the class.
|
||||
@@ -965,7 +1001,7 @@ pub fn write_all_students(
|
||||
///
|
||||
/// A Markdown table.
|
||||
pub fn objective_summary(cohort: &Cohort, course: &CourseFile) -> String {
|
||||
let mut out = String::from("| Objective | Class rate |\n|:--|--:|\n");
|
||||
let mut out = String::from("| Objective | Class rate | Items |\n|:--|--:|--:|\n");
|
||||
let mut rows: Vec<(&String, &f64)> = cohort.objective_rates.iter().collect();
|
||||
rows.sort_by(|a, b| {
|
||||
a.1.partial_cmp(b.1)
|
||||
@@ -973,10 +1009,20 @@ pub fn objective_summary(cohort: &Cohort, course: &CourseFile) -> String {
|
||||
.then_with(|| a.0.cmp(b.0))
|
||||
});
|
||||
for (id, rate) in rows {
|
||||
// Items behind the rate, because a rate without a denominator is what
|
||||
// makes a committee table misleading.
|
||||
let n = cohort
|
||||
.students
|
||||
.iter()
|
||||
.flat_map(|s| s.objectives.iter())
|
||||
.find(|o| &o.id == id)
|
||||
.map(|o| o.n_items)
|
||||
.unwrap_or(0);
|
||||
out.push_str(&format!(
|
||||
"| {} | {:.0}% |\n",
|
||||
escape_pipes(&course.objective_text(id)),
|
||||
rate * 100.0
|
||||
"| {} | {:.0}% | {} |\n",
|
||||
escape_pipes(&course.text_for(id)),
|
||||
rate * 100.0,
|
||||
n
|
||||
));
|
||||
}
|
||||
out
|
||||
|
||||
+21
-26
@@ -34,7 +34,7 @@
|
||||
|
||||
use crate::assessment::{AssessmentFile, Form, Placement};
|
||||
use crate::catalog::Catalog;
|
||||
use crate::course::{CourseFile, Reference};
|
||||
use crate::course::CourseFile;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::item::{Choice, Citation, Item, Solution};
|
||||
use crate::markup;
|
||||
@@ -226,12 +226,13 @@ fn question_block(
|
||||
|
||||
if item.has_options() {
|
||||
b.push_str(":::: {.q-choices}\n");
|
||||
let order = select::option_order(form, &placement.item, item.options.len());
|
||||
let shown = item.administered(&placement.key, &placement.distractors);
|
||||
let order = select::option_order(form, &placement.item, shown.len());
|
||||
for (position, &source) in order.iter().enumerate() {
|
||||
b.push_str(&format!(
|
||||
"{}. {}\n",
|
||||
position + 1,
|
||||
markup::to_markdown(&item.options[source].text)
|
||||
markup::to_markdown(&shown[source].text)
|
||||
));
|
||||
}
|
||||
b.push_str("::::\n\n");
|
||||
@@ -295,11 +296,12 @@ fn fragment(
|
||||
/// A single-best-answer or multiple-response fragment: the key, the model answer,
|
||||
/// the explanation, then per-distractor feedback.
|
||||
fn choice_fragment(course: &CourseFile, placement: &Placement, item: &Item, form: &Form) -> String {
|
||||
let order = select::option_order(form, &placement.item, item.options.len());
|
||||
let shown = item.administered(&placement.key, &placement.distractors);
|
||||
let order = select::option_order(form, &placement.item, shown.len());
|
||||
let printed: Vec<(usize, &Choice)> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(position, &source)| (position, &item.options[source]))
|
||||
.map(|(position, &source)| (position, shown[source]))
|
||||
.collect();
|
||||
|
||||
let mut out = String::new();
|
||||
@@ -472,7 +474,7 @@ fn cite_html(course: &CourseFile, citation: &Citation) -> String {
|
||||
let Some(reference) = course.references.get(key) else {
|
||||
return markup::escape_html(&citation.display());
|
||||
};
|
||||
let label = reference.label.as_deref().unwrap_or(key);
|
||||
let label = reference.label_or(key);
|
||||
let locator = citation.locator.as_deref().unwrap_or("");
|
||||
let body = if locator.is_empty() {
|
||||
markup::escape_html(label)
|
||||
@@ -483,27 +485,12 @@ fn cite_html(course: &CourseFile, citation: &Citation) -> String {
|
||||
markup::escape_html(locator)
|
||||
)
|
||||
};
|
||||
match resolve_url(citation, reference) {
|
||||
match citation.href(reference) {
|
||||
Some(url) => format!("<a href=\"{}\">{body}</a>", markup::escape_html(&url)),
|
||||
None => body,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a citation's link, from an explicit URL or a path joined to the
|
||||
/// reference's base URL.
|
||||
fn resolve_url(citation: &Citation, reference: &Reference) -> Option<String> {
|
||||
if let Some(url) = &citation.url {
|
||||
return Some(url.clone());
|
||||
}
|
||||
let path = citation.path.as_deref()?;
|
||||
let base = reference.base_url.as_deref()?;
|
||||
Some(match (base.ends_with('/'), path.starts_with('/')) {
|
||||
(true, true) => format!("{base}{}", &path[1..]),
|
||||
(false, false) => format!("{base}/{path}"),
|
||||
_ => format!("{base}{path}"),
|
||||
})
|
||||
}
|
||||
|
||||
// --- math-aware markup ---
|
||||
|
||||
/// One run of source text, split on math delimiters.
|
||||
@@ -803,7 +790,7 @@ items:
|
||||
level: 2
|
||||
format: single_best_answer
|
||||
stem: "The heat at constant pressure equals a change in what?"
|
||||
learning_objectives: [lo-enthalpy]
|
||||
learning_targets: [lo-enthalpy]
|
||||
options:
|
||||
- { id: A, text: "Enthalpy, $\\Delta H$", correct: true, feedback_student: "Right, $q_p = \\Delta H$." }
|
||||
- { id: B, text: "Internal energy, $\\Delta U$", misconception: "Uses the constant-volume result.", explanation: "That holds only at constant volume." }
|
||||
@@ -816,7 +803,7 @@ items:
|
||||
level: 3
|
||||
format: open_response
|
||||
stem: "Show why $q_p = \\Delta H$."
|
||||
learning_objectives: [lo-enthalpy]
|
||||
learning_targets: [lo-enthalpy]
|
||||
solution:
|
||||
model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$."
|
||||
explanation: |
|
||||
@@ -858,29 +845,37 @@ items:
|
||||
number: 1,
|
||||
item: "b::q-mcq".into(),
|
||||
version: None,
|
||||
stem_digest: None,
|
||||
distractors: Vec::new(),
|
||||
variant: None,
|
||||
fingerprint: None,
|
||||
points: Some(1.0),
|
||||
bonus: false,
|
||||
key: vec!["A".into()],
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
learning_targets: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: false,
|
||||
dropped_as: None,
|
||||
dropped_before_printing: false,
|
||||
},
|
||||
Placement {
|
||||
number: 2,
|
||||
item: "b::q-open".into(),
|
||||
version: None,
|
||||
stem_digest: None,
|
||||
distractors: Vec::new(),
|
||||
variant: None,
|
||||
fingerprint: None,
|
||||
points: Some(2.0),
|
||||
bonus: false,
|
||||
key: Vec::new(),
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
learning_targets: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: false,
|
||||
dropped_as: None,
|
||||
dropped_before_printing: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
+24
-4
@@ -69,6 +69,7 @@ use crate::Layout;
|
||||
use crate::assessment::{AssessmentFile, Form};
|
||||
use crate::catalog::Catalog;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::markup;
|
||||
|
||||
/// What to render.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -195,6 +196,17 @@ pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Res
|
||||
}
|
||||
|
||||
let mut warnings = payload::check(&payload, &opts.config);
|
||||
for (slot, body) in &bodies {
|
||||
if markup::needs_chem_import(&template.source, body) {
|
||||
warnings.push(format!(
|
||||
"the `{}` slot carries a chemical formula, but the template {} does not import \
|
||||
whalogen, so Typst will stop at `unknown variable: ce`; add `{}`",
|
||||
slot.as_str(),
|
||||
template.origin,
|
||||
markup::CHEM_IMPORT
|
||||
));
|
||||
}
|
||||
}
|
||||
if template.is_inert() {
|
||||
warnings.push(format!(
|
||||
"the template {} declares no coursebank markers, so no questions were injected; add \
|
||||
@@ -419,37 +431,45 @@ mod tests {
|
||||
number: 1,
|
||||
item: "b::q-1".into(),
|
||||
version: None,
|
||||
stem_digest: None,
|
||||
distractors: Vec::new(),
|
||||
variant: None,
|
||||
fingerprint: None,
|
||||
points: None,
|
||||
bonus: false,
|
||||
key: vec!["A".into()],
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
learning_targets: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: true,
|
||||
dropped_as: None,
|
||||
dropped_before_printing: false,
|
||||
},
|
||||
Placement {
|
||||
number: 2,
|
||||
item: "b::q-2".into(),
|
||||
version: None,
|
||||
stem_digest: None,
|
||||
distractors: Vec::new(),
|
||||
variant: None,
|
||||
fingerprint: None,
|
||||
points: None,
|
||||
bonus: false,
|
||||
key: vec!["B".into()],
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
learning_targets: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: false,
|
||||
dropped_as: None,
|
||||
dropped_before_printing: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
let printable: Vec<u32> = select::layout(&record, &Options::default().form)
|
||||
.into_iter()
|
||||
.filter(|p| !p.dropped)
|
||||
.filter(|p| p.was_printed())
|
||||
.map(|p| p.number)
|
||||
.collect();
|
||||
assert_eq!(printable, vec![2]);
|
||||
assert_eq!(printable, vec![1, 2]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -869,4 +869,4 @@ mod tests {
|
||||
assert_eq!(file.resolve(Variant::Exam).reveal, Reveal::Nothing);
|
||||
assert_eq!(file.resolve(Variant::Key).reveal, Reveal::Everything);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+131
-23
@@ -321,8 +321,8 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
|
||||
value.insert_some("position", question.position.map(|p| Value::Int(p as i64)));
|
||||
value.insert_some("level", question.level.map(|l| Value::Int(l as i64)));
|
||||
value.insert(
|
||||
"objectives",
|
||||
Value::Array(question.objectives.iter().map(|o| Value::str(o)).collect()),
|
||||
"targets",
|
||||
Value::Array(question.targets.iter().map(Value::str).collect()),
|
||||
);
|
||||
value.insert_some("correct", question.correct.map(Value::Bool));
|
||||
value.insert("credit", Value::Float(question.credit));
|
||||
@@ -330,13 +330,28 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
|
||||
value.insert("dropped", Value::Bool(question.dropped));
|
||||
value.insert("blank", Value::Bool(question.blank));
|
||||
value.insert_some("class-rate", question.class_rate.map(Value::Float));
|
||||
// Both tiers, as a list of pairs: the target says what this
|
||||
// question asked, the objective says which row of the table
|
||||
// above it counted toward. `objective` is absent when the
|
||||
// tagged id is an objective with no targets, so the template
|
||||
// does not print one sentence twice.
|
||||
value.insert(
|
||||
"objective-texts",
|
||||
"measured",
|
||||
Value::Array(
|
||||
question
|
||||
.objective_texts
|
||||
.measured
|
||||
.iter()
|
||||
.map(|text| markup_value(text, content))
|
||||
.map(|m| {
|
||||
let mut pair = Value::dict();
|
||||
pair.insert_some(
|
||||
"objective",
|
||||
m.objective
|
||||
.as_ref()
|
||||
.map(|text| markup_value(text, content)),
|
||||
);
|
||||
pair.insert("target", markup_value(&m.target, content));
|
||||
pair
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
@@ -350,7 +365,7 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
|
||||
}
|
||||
value.insert(
|
||||
"taught-in",
|
||||
Value::Array(question.taught_in.iter().map(|s| Value::str(s)).collect()),
|
||||
Value::Array(question.taught_in.iter().map(Value::str).collect()),
|
||||
);
|
||||
value.insert(
|
||||
"review",
|
||||
@@ -412,7 +427,7 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
|
||||
value.insert("lecture", Value::str(&lecture.lecture));
|
||||
value.insert("title", Value::str(&lecture.title));
|
||||
value.insert_some("url", lecture.url.as_ref().map(Value::str));
|
||||
value.insert("objectives-missed", Value::Int(lecture.n_objectives as i64));
|
||||
value.insert("targets-missed", Value::Int(lecture.n_targets as i64));
|
||||
value.insert("questions-missed", Value::Int(lecture.n_questions as i64));
|
||||
value.insert(
|
||||
"questions",
|
||||
@@ -435,10 +450,10 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
|
||||
),
|
||||
);
|
||||
value.insert(
|
||||
"objectives",
|
||||
"targets",
|
||||
Value::Array(
|
||||
lecture
|
||||
.objectives
|
||||
.targets
|
||||
.iter()
|
||||
.map(|text| markup_value(text, content))
|
||||
.collect(),
|
||||
@@ -605,6 +620,18 @@ pub fn cohort_value(diagnostic: &CohortDiagnostic, config: &RenderConfig) -> Val
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
// Separate from `questions` so no statistic can pick them up, and merged
|
||||
// back in by the evidence section, which describes rather than measures.
|
||||
out.insert(
|
||||
"dropped-detail",
|
||||
Value::Array(
|
||||
diagnostic
|
||||
.dropped_detail
|
||||
.iter()
|
||||
.map(|q| cohort_question_value(q, content))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
|
||||
out.insert(
|
||||
"grades",
|
||||
@@ -741,7 +768,7 @@ pub fn cohort_value(diagnostic: &CohortDiagnostic, config: &RenderConfig) -> Val
|
||||
|
||||
out.insert(
|
||||
"blueprint",
|
||||
Value::Array(diagnostic.blueprint.iter().map(|s| Value::str(s)).collect()),
|
||||
Value::Array(diagnostic.blueprint.iter().map(Value::str).collect()),
|
||||
);
|
||||
|
||||
out.insert(
|
||||
@@ -767,7 +794,7 @@ pub fn cohort_value(diagnostic: &CohortDiagnostic, config: &RenderConfig) -> Val
|
||||
|
||||
out.insert(
|
||||
"warnings",
|
||||
Value::Array(diagnostic.warnings.iter().map(|s| Value::str(s)).collect()),
|
||||
Value::Array(diagnostic.warnings.iter().map(Value::str).collect()),
|
||||
);
|
||||
|
||||
out
|
||||
@@ -783,9 +810,9 @@ fn triage_value(row: &crate::diagnostic::TriageRow, content: bool) -> Value {
|
||||
value.insert_some("point-biserial", row.point_biserial.map(Value::Float));
|
||||
value.insert_some("discrimination", row.discrimination.map(Value::Float));
|
||||
value.insert(
|
||||
"objectives",
|
||||
"targets",
|
||||
Value::Array(
|
||||
row.objectives
|
||||
row.targets
|
||||
.iter()
|
||||
.map(|text| markup_value(text, content))
|
||||
.collect(),
|
||||
@@ -793,7 +820,7 @@ fn triage_value(row: &crate::diagnostic::TriageRow, content: bool) -> Value {
|
||||
);
|
||||
value.insert(
|
||||
"taught-in",
|
||||
Value::Array(row.taught_in.iter().map(|t| Value::str(t)).collect()),
|
||||
Value::Array(row.taught_in.iter().map(Value::str).collect()),
|
||||
);
|
||||
value.insert_some("option", row.option.as_ref().map(Value::str));
|
||||
value.insert_some("option-share", row.option_share.map(Value::Float));
|
||||
@@ -844,26 +871,38 @@ fn cohort_question_value(question: &CohortQuestionRow, content: bool) -> Value {
|
||||
value.insert_some("item", question.item.as_ref().map(Value::str));
|
||||
value.insert_some("level", question.level.map(|l| Value::Int(l as i64)));
|
||||
value.insert(
|
||||
"objectives",
|
||||
Value::Array(question.objectives.iter().map(|o| Value::str(o)).collect()),
|
||||
"targets",
|
||||
Value::Array(question.targets.iter().map(Value::str).collect()),
|
||||
);
|
||||
value.insert(
|
||||
"objective-texts",
|
||||
"target-texts",
|
||||
Value::Array(
|
||||
question
|
||||
.objective_texts
|
||||
.target_texts
|
||||
.iter()
|
||||
.map(|text| markup_value(text, content))
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
value.insert_some(
|
||||
"stem",
|
||||
question
|
||||
.stem
|
||||
.as_ref()
|
||||
.map(|text| markup_value(text, content)),
|
||||
);
|
||||
value.insert("dropped", Value::Bool(question.dropped));
|
||||
value.insert(
|
||||
"dropped-full-credit",
|
||||
Value::Bool(question.dropped_full_credit),
|
||||
);
|
||||
value.insert(
|
||||
"taught-in",
|
||||
Value::Array(question.taught_in.iter().map(|t| Value::str(t)).collect()),
|
||||
Value::Array(question.taught_in.iter().map(Value::str).collect()),
|
||||
);
|
||||
value.insert(
|
||||
"lectures",
|
||||
Value::Array(question.lectures.iter().map(|l| Value::str(l)).collect()),
|
||||
Value::Array(question.lectures.iter().map(Value::str).collect()),
|
||||
);
|
||||
value.insert("difficulty-band", Value::str(&question.difficulty_band));
|
||||
value.insert(
|
||||
@@ -876,7 +915,7 @@ fn cohort_question_value(question: &CohortQuestionRow, content: bool) -> Value {
|
||||
value.insert("blank-rate", Value::Float(question.blank_rate));
|
||||
value.insert(
|
||||
"key",
|
||||
Value::Array(question.key.iter().map(|k| Value::str(k)).collect()),
|
||||
Value::Array(question.key.iter().map(Value::str).collect()),
|
||||
);
|
||||
value.insert(
|
||||
"options",
|
||||
@@ -887,6 +926,27 @@ fn cohort_question_value(question: &CohortQuestionRow, content: bool) -> Value {
|
||||
.map(|option| {
|
||||
let mut value = Value::dict();
|
||||
value.insert("letter", Value::str(&option.letter));
|
||||
value.insert_some(
|
||||
"text",
|
||||
option.text.as_ref().map(|text| markup_value(text, content)),
|
||||
);
|
||||
// The letter on each paper, so a statistic reported against
|
||||
// the bank letter can be checked against a student's copy.
|
||||
value.insert(
|
||||
"printed",
|
||||
Value::Array(
|
||||
option
|
||||
.printed
|
||||
.iter()
|
||||
.map(|printed| {
|
||||
let mut pair = Value::dict();
|
||||
pair.insert("form", Value::str(&printed.form));
|
||||
pair.insert("letter", Value::str(&printed.letter));
|
||||
pair
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
value.insert("count", Value::Int(option.count as i64));
|
||||
value.insert("rate", Value::Float(option.rate));
|
||||
value.insert("is-key", Value::Bool(option.is_key));
|
||||
@@ -899,7 +959,7 @@ fn cohort_question_value(question: &CohortQuestionRow, content: bool) -> Value {
|
||||
);
|
||||
value.insert(
|
||||
"flags",
|
||||
Value::Array(question.flags.iter().map(|f| Value::str(f)).collect()),
|
||||
Value::Array(question.flags.iter().map(Value::str).collect()),
|
||||
);
|
||||
value.insert(
|
||||
"notes",
|
||||
@@ -1031,6 +1091,17 @@ fn render(
|
||||
}
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
for (slot, body) in &bodies {
|
||||
if crate::markup::needs_chem_import(&template.source, body) {
|
||||
warnings.push(format!(
|
||||
"the `{}` slot carries a chemical formula, but the template {} does not import \
|
||||
whalogen, so Typst will stop at `unknown variable: ce`; add `{}`",
|
||||
slot.as_str(),
|
||||
template.origin,
|
||||
crate::markup::CHEM_IMPORT
|
||||
));
|
||||
}
|
||||
}
|
||||
if template.is_inert() {
|
||||
warnings.push(format!(
|
||||
"the template {} declares no coursebank markers, so the report is empty; add `// \
|
||||
@@ -1210,9 +1281,46 @@ mod tests {
|
||||
blueprint: Vec::new(),
|
||||
patterns: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
dropped_detail: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_markup_takes_the_same_path_as_a_paper() {
|
||||
for source in [
|
||||
"$\\ce{H2O <=> H+ + OH-}$",
|
||||
"the backbone $\\ce{-C=O}$ group",
|
||||
"$K_w = [\\text{H}^+][\\text{OH}^-]$",
|
||||
"see @fig:x where x < y",
|
||||
"costs \\$5, and just $5",
|
||||
] {
|
||||
let expected = crate::markup::to_typst(source);
|
||||
assert_eq!(
|
||||
markup_value(source, true).to_typst(0),
|
||||
format!("[{expected}]"),
|
||||
"content mode diverged on {source:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
markup_value(source, false).to_typst(0),
|
||||
Value::str(expected).to_typst(0),
|
||||
"string mode diverged on {source:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_report_carrying_chemistry_names_a_template_missing_the_import() {
|
||||
let body = "#let cb-data = (stem: [#ce(\"H2O\")])";
|
||||
assert!(crate::markup::needs_chem_import(
|
||||
"#import \"@preview/mitex:0.2.7\": mi\n// coursebank:data\n",
|
||||
body
|
||||
));
|
||||
assert!(!crate::markup::needs_chem_import(
|
||||
crate::markup::CHEM_IMPORT,
|
||||
body
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_headline_leads_with_the_distribution() {
|
||||
let lines = headline(&empty_cohort());
|
||||
@@ -1236,4 +1344,4 @@ mod tests {
|
||||
assert!(text.contains("reliability"));
|
||||
assert!(!text.contains("stem"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+37
-88
@@ -50,7 +50,7 @@ pub struct Payload {
|
||||
pub form: FormInfo,
|
||||
/// Counts and sums, so a template does not have to derive them.
|
||||
pub totals: Totals,
|
||||
/// Learning objectives referenced by the printed items, by id.
|
||||
/// Learning objectives and targets referenced by the printed items, by id.
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub objectives: BTreeMap<String, Objective>,
|
||||
/// Shared stimuli, by id. Populated when the render config says stimuli are
|
||||
@@ -268,9 +268,9 @@ pub struct Question {
|
||||
/// letter. Omitted unless the config reveals the key.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub credit_overrides: Option<BTreeMap<String, f64>>,
|
||||
/// Learning objective ids.
|
||||
/// Learning target ids.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub learning_objectives: Vec<String>,
|
||||
pub learning_targets: Vec<String>,
|
||||
/// Topic tags.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub topics: Vec<String>,
|
||||
@@ -386,10 +386,10 @@ pub fn build(
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let objectives = if placement.learning_objectives.is_empty() {
|
||||
item.learning_objectives.clone()
|
||||
let objectives = if placement.learning_targets.is_empty() {
|
||||
item.learning_targets.clone()
|
||||
} else {
|
||||
placement.learning_objectives.clone()
|
||||
placement.learning_targets.clone()
|
||||
};
|
||||
if config.fields.objectives {
|
||||
objective_ids.extend(objectives.iter().cloned());
|
||||
@@ -411,11 +411,12 @@ pub fn build(
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
let order = select::option_order(form, &placement.item, item.options.len());
|
||||
let shown = item.administered(&placement.key, &placement.distractors);
|
||||
let order = select::option_order(form, &placement.item, shown.len());
|
||||
let options: Vec<Opt> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(position, source_index)| option(&item.options[*source_index], position, config))
|
||||
.map(|(position, source_index)| option(shown[*source_index], position, config))
|
||||
.collect();
|
||||
|
||||
let key = if config.reveal.shows_key() {
|
||||
@@ -467,7 +468,7 @@ pub fn build(
|
||||
options,
|
||||
key,
|
||||
credit_overrides,
|
||||
learning_objectives: if config.fields.objectives {
|
||||
learning_targets: if config.fields.objectives {
|
||||
objectives
|
||||
} else {
|
||||
Vec::new()
|
||||
@@ -501,14 +502,10 @@ pub fn build(
|
||||
let objectives = objective_ids
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
course.learning_objectives.get(&id).map(|o| {
|
||||
(
|
||||
id,
|
||||
Objective {
|
||||
text: markup::to_typst(&o.text),
|
||||
unit: o.unit.clone(),
|
||||
},
|
||||
)
|
||||
(course.is_objective(&id) || course.is_target(&id)).then(|| {
|
||||
let text = markup::to_typst(&course.text_for(&id));
|
||||
let unit = course.objective_unit(&id).map(str::to_string);
|
||||
(id, Objective { text, unit })
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -960,12 +957,12 @@ fn question_value(question: &Question, config: &RenderConfig) -> Value {
|
||||
}),
|
||||
);
|
||||
|
||||
if !question.learning_objectives.is_empty() {
|
||||
if !question.learning_targets.is_empty() {
|
||||
root.insert(
|
||||
"learning-objectives",
|
||||
"learning-targets",
|
||||
Value::Array(
|
||||
question
|
||||
.learning_objectives
|
||||
.learning_targets
|
||||
.iter()
|
||||
.map(|s| Value::str(s.as_str()))
|
||||
.collect(),
|
||||
@@ -1070,61 +1067,27 @@ fn numeric_map(map: &BTreeMap<String, f64>) -> Value {
|
||||
)
|
||||
}
|
||||
|
||||
/// Rewrites inline LaTeX math (`$...$`) into a call to mitex's `mi`, so Typst's
|
||||
/// own math grammar never has to parse it. Typst's `\times`, `\Delta`, `\ln` and
|
||||
/// friends are not valid Typst math — a bare backslash escapes the next
|
||||
/// character instead of naming a symbol — which is why equations compile but
|
||||
/// print wrong instead of failing outright. `\$` is left alone, matching LaTeX's
|
||||
/// own convention for a literal dollar sign, and a `$` with no matching close is
|
||||
/// left alone too, rather than swallowing the rest of the field.
|
||||
fn rewrite_latex_math(source: &str) -> String {
|
||||
let chars: Vec<(usize, char)> = source.char_indices().collect();
|
||||
let mut out = String::with_capacity(source.len());
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let (_, c) = chars[i];
|
||||
if c == '\\' && i + 1 < chars.len() {
|
||||
out.push('\\');
|
||||
out.push(chars[i + 1].1);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if c != '$' {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut j = i + 1;
|
||||
let close = loop {
|
||||
if j >= chars.len() {
|
||||
break None;
|
||||
}
|
||||
match chars[j].1 {
|
||||
'\\' => j += 2,
|
||||
'$' => break Some(j),
|
||||
_ => j += 1,
|
||||
}
|
||||
};
|
||||
match close {
|
||||
Some(close_idx) => {
|
||||
let start = chars[i + 1].0;
|
||||
let end = chars[close_idx].0;
|
||||
out.push_str("#mi(");
|
||||
out.push_str(&Value::str(&source[start..end]).to_typst(0));
|
||||
out.push(')');
|
||||
i = close_idx + 1;
|
||||
}
|
||||
None => {
|
||||
out.push('$');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Emits one markup-bearing field for a diagnostic report.
|
||||
///
|
||||
/// The conversion itself lives in [`markup::to_typst`], which is the single
|
||||
/// entry point for turning authoring markup into Typst: it rewrites inline
|
||||
/// LaTeX math, routes mhchem through whalogen, and escapes the characters Typst
|
||||
/// treats specially in content mode. This path used to carry its own copy of
|
||||
/// the math rewriting, which drifted — the reports escaped nothing outside math
|
||||
/// and silently disagreed with the exam papers about an unmatched `$`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `source` - the authoring source.
|
||||
/// * `content` - whether to emit a content block rather than a quoted string.
|
||||
/// A string is evaluated by the template with `eval(.., mode: "markup")`, so
|
||||
/// both modes need the same escaping.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The value to place in the payload.
|
||||
pub(crate) fn markup_value(source: &str, content: bool) -> Value {
|
||||
let source = rewrite_latex_math(source);
|
||||
let source = markup::to_typst(source);
|
||||
if content {
|
||||
Value::content(source)
|
||||
} else {
|
||||
@@ -1274,17 +1237,3 @@ mod tests {
|
||||
assert!(!balanced("closing ] first"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latex_math_becomes_a_mitex_call() {
|
||||
assert_eq!(
|
||||
rewrite_latex_math("angle $\\phi$ (phi)"),
|
||||
"angle #mi(\"\\\\phi\") (phi)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_and_unmatched_dollar_signs_are_left_alone() {
|
||||
assert_eq!(rewrite_latex_math("costs \\$5 total"), "costs \\$5 total");
|
||||
assert_eq!(rewrite_latex_math("just $5"), "just $5");
|
||||
}
|
||||
|
||||
@@ -110,8 +110,8 @@
|
||||
number: 1,
|
||||
item: "bank::q-sample-001",
|
||||
level: 1,
|
||||
objectives: ("lo-sample-gap",),
|
||||
objective-texts: ([A sample objective the class struggled with.],),
|
||||
targets: ("t-sample-gap",),
|
||||
target-texts: ([A sample learning target the class struggled with.],),
|
||||
taught-in: ("Entropy (L1.2), slides 4, 5",),
|
||||
lectures: ("L1.2",),
|
||||
difficulty-band: "moderate",
|
||||
@@ -121,11 +121,32 @@
|
||||
discrimination: 0.10,
|
||||
blank-rate: 0.0,
|
||||
key: ("B",),
|
||||
stem: [A sample question stem, shown so the option shares can be read against what was asked.],
|
||||
options: (
|
||||
(letter: "A", count: 9, rate: 0.375, is-key: false, point-biserial: 0.11, nonfunctioning: false),
|
||||
(letter: "B", count: 10, rate: 0.417, is-key: true, point-biserial: 0.05, nonfunctioning: false),
|
||||
(letter: "C", count: 5, rate: 0.208, is-key: false, point-biserial: -0.2, nonfunctioning: false),
|
||||
(letter: "D", count: 0, rate: 0.0, is-key: false, nonfunctioning: true),
|
||||
(
|
||||
letter: "A",
|
||||
text: [A sample distractor built on a real misconception.],
|
||||
printed: ((form: "A", letter: "C"), (form: "B", letter: "A")),
|
||||
count: 9, rate: 0.375, is-key: false, point-biserial: 0.11, nonfunctioning: false,
|
||||
),
|
||||
(
|
||||
letter: "B",
|
||||
text: [A sample key.],
|
||||
printed: ((form: "A", letter: "D"), (form: "B", letter: "C")),
|
||||
count: 10, rate: 0.417, is-key: true, point-biserial: 0.05, nonfunctioning: false,
|
||||
),
|
||||
(
|
||||
letter: "C",
|
||||
text: [A second sample distractor.],
|
||||
printed: ((form: "A", letter: "A"), (form: "B", letter: "D")),
|
||||
count: 5, rate: 0.208, is-key: false, point-biserial: -0.2, nonfunctioning: false,
|
||||
),
|
||||
(
|
||||
letter: "D",
|
||||
text: [A distractor nobody chose.],
|
||||
printed: ((form: "A", letter: "B"), (form: "B", letter: "B")),
|
||||
count: 0, rate: 0.0, is-key: false, nonfunctioning: true,
|
||||
),
|
||||
),
|
||||
flags: ("ambiguous",),
|
||||
notes: ([Distractor A drew as many strong students as the key.],),
|
||||
@@ -168,6 +189,33 @@
|
||||
biggest-surprise: (number: 14, expected: 0.45, observed: 0.86),
|
||||
),
|
||||
dropped-questions: (),
|
||||
dropped-detail: (
|
||||
(
|
||||
number: 9,
|
||||
item: "bank::q-sample-009",
|
||||
level: 2,
|
||||
targets: ("t-sample-gap",),
|
||||
target-texts: ([A sample learning target.],),
|
||||
dropped: true,
|
||||
dropped-full-credit: true,
|
||||
stem: [A sample question that was thrown out after the exam.],
|
||||
taught-in: ("Entropy (L1.2)",),
|
||||
lectures: ("L1.2",),
|
||||
p: 0.18,
|
||||
blank-rate: 0.0,
|
||||
key: ("A",),
|
||||
options: (
|
||||
(letter: "A", text: [The keyed option, which few chose.], count: 4, rate: 0.18, is-key: true, nonfunctioning: false),
|
||||
(letter: "B", text: [The option most students read as correct.], count: 15, rate: 0.68, is-key: false, nonfunctioning: false),
|
||||
(letter: "C", text: [A third option.], count: 3, rate: 0.14, is-key: false, nonfunctioning: false),
|
||||
),
|
||||
flags: (),
|
||||
notes: (),
|
||||
prediction-notes: (),
|
||||
calibrated: false,
|
||||
by-form: (:),
|
||||
),
|
||||
),
|
||||
revise: (),
|
||||
forms: (
|
||||
(id: "A", students: 12, mean: 73.5, sd: 10.2),
|
||||
@@ -349,12 +397,14 @@
|
||||
// The objective and lecture context for one question.
|
||||
#let question-context(row) = {
|
||||
let parts = ()
|
||||
// A question row carries ids in `objectives` and prose in `objective-texts`; a
|
||||
// triage row carries the prose under `objectives`. One lookup covers both.
|
||||
let objectives = row.at("objective-texts", default: row.at("objectives", default: ()))
|
||||
if objectives.len() > 0 {
|
||||
// A question row carries ids in `targets` and prose in `target-texts`; a
|
||||
// triage row carries the prose under `targets`. One lookup covers both. This
|
||||
// is the target tier on purpose: a row about one item should name the
|
||||
// performance that item measured, not the broader claim it rolls up to.
|
||||
let targets = row.at("target-texts", default: row.at("targets", default: ()))
|
||||
if targets.len() > 0 {
|
||||
parts.push(text(size: size-meta, fill: luma(105))[
|
||||
#text(weight: "bold")[Measured:] #objectives.map(o => markup(o)).join([; ])
|
||||
#text(weight: "bold")[Measured:] #targets.map(t => markup(t)).join([; ])
|
||||
])
|
||||
}
|
||||
let taught = row.at("taught-in", default: ())
|
||||
@@ -1075,23 +1125,45 @@
|
||||
]
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// The revise queue, with option tables
|
||||
// Every question, with option tables
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#let revise = cb-data.at("revise", default: ())
|
||||
// Every scored question, not only the flagged ones. A question that raised no
|
||||
// flag still has an option spread worth seeing: it is the reference for what a
|
||||
// healthy item looks like on this exam, and the place to check a query about a
|
||||
// question nothing was wrong with. The flagged ones are already called out by
|
||||
// name in the triage lists above, so nothing is lost by putting them back among
|
||||
// their neighbours here.
|
||||
// Dropped items come from their own list, since they carry no statistics and
|
||||
// belong in none of the tables above. They belong here: the option spread is
|
||||
// the evidence that justified dropping them, and re-running the report after a
|
||||
// drop should not erase it.
|
||||
#let evidence = questions + cb-data.at("dropped-detail", default: ())
|
||||
|
||||
#if revise.len() > 0 [
|
||||
#if evidence.len() > 0 [
|
||||
#pagebreak(weak: true)
|
||||
= The evidence, question by question
|
||||
|
||||
#explain[
|
||||
The full breakdown for every flagged question: what the flags mean, and who
|
||||
chose what. The r column beside each option is the correlation between
|
||||
choosing that option and scoring well on the rest of the exam, which is how a
|
||||
defensible distractor announces itself.
|
||||
The full breakdown for every question, flagged or not, including the ones
|
||||
you dropped: what it asked, who chose what, and what any flags mean. A
|
||||
dropped question is marked as such and carries no p, r, or D, because
|
||||
dropping it overrode its credit. What students marked is untouched by the
|
||||
drop, so its option spread still stands as the record of why it went. In question order, so you can find
|
||||
one by its number; the lists above are the subset that needs a decision from
|
||||
you, sorted by what they ask of you. The r column beside each option is the
|
||||
correlation between choosing that option and scoring well on the rest of the
|
||||
exam, which is how a defensible distractor announces itself.
|
||||
|
||||
Each option shows the bank letter first, then what it was lettered on each
|
||||
form (#raw("A:D") means the bank's option was printed as D on form A), so a
|
||||
row here can be read against the paper a student is holding.
|
||||
]
|
||||
|
||||
#for q in revise [
|
||||
// Question order here, not triage order. The lists above are for deciding
|
||||
// what to do; this section is for looking one question up while you do it,
|
||||
// and a reader with a number in hand should not have to scan every entry.
|
||||
#for q in evidence.sorted(key: q => q.number) [
|
||||
#block(breakable: false, above: entry-gap, width: 100%)[
|
||||
#{
|
||||
let parts = (
|
||||
@@ -1099,6 +1171,11 @@
|
||||
let bits = (text(weight: "bold", fill: accent)[Question #q.number],)
|
||||
let item = q.at("item", default: none)
|
||||
if item != none { bits.push(text(size: size-meta, fill: luma(120))[#item]) }
|
||||
if q.at("dropped", default: false) {
|
||||
bits.push(text(size: size-micro, weight: "bold", fill: luma(110))[
|
||||
DROPPED#if q.at("dropped-full-credit", default: false) [ · FULL CREDIT]
|
||||
])
|
||||
}
|
||||
let flags = q.at("flags", default: ())
|
||||
if flags.len() > 0 {
|
||||
bits.push(stack(dir: ltr, spacing: 3pt, ..flags.map(flag-badge)))
|
||||
@@ -1110,6 +1187,20 @@
|
||||
let context_ = question-context(q)
|
||||
if context_ != none { parts.push(context_) }
|
||||
|
||||
// The question itself. A row of option shares says a distractor drew
|
||||
// 44% of the class; only the stem says whether that is a second
|
||||
// defensible reading. Instructor copy only, which is the only place this
|
||||
// section appears.
|
||||
let stem = q.at("stem", default: none)
|
||||
if stem != none {
|
||||
parts.push(block(
|
||||
width: 100%,
|
||||
fill: luma(252),
|
||||
stroke: (left: 2pt + luma(220)),
|
||||
inset: (left: 8pt, rest: 6pt),
|
||||
)[#text(size: size-small)[#markup(stem)]])
|
||||
}
|
||||
|
||||
for note in q.at("notes", default: ()) {
|
||||
parts.push(text(size: size-small)[— #markup(note)])
|
||||
}
|
||||
@@ -1134,19 +1225,50 @@
|
||||
}
|
||||
|
||||
if show-options and q.at("options", default: ()).len() > 0 {
|
||||
// The option column is two things: the bank letter every statistic is
|
||||
// keyed by, and the letter the option actually carried on each paper.
|
||||
// Without the second, a note about option D cannot be checked against
|
||||
// the copy a student brings to office hours, since shuffling gives the
|
||||
// same option a different letter on every form.
|
||||
//
|
||||
// The text goes last and takes the free column. It is the only cell
|
||||
// whose length is not bounded, so anywhere else it either squeezes the
|
||||
// numbers or wraps to three lines while they sit in a narrow gutter.
|
||||
// Last, the numbers keep their natural widths and the text runs to the
|
||||
// page edge.
|
||||
parts.push(table(
|
||||
columns: (auto, auto, auto, 2.6cm, auto),
|
||||
columns: (auto, auto, auto, 2.6cm, auto, 1fr),
|
||||
stroke: none,
|
||||
align: (center + horizon, right + horizon, right + horizon, left + horizon, right + horizon),
|
||||
align: (
|
||||
center + horizon,
|
||||
right + horizon,
|
||||
right + horizon,
|
||||
left + horizon,
|
||||
right + horizon,
|
||||
left + top,
|
||||
),
|
||||
inset: (x: 5pt, y: 3.5pt),
|
||||
table.header(th[OPT], th[n], th[SHARE], th[], th[r]),
|
||||
table.header(th[OPT], th[n], th[SHARE], th[], th[r], th[TEXT]),
|
||||
..q
|
||||
.options
|
||||
.map(option => (
|
||||
{
|
||||
if option.at("is-key", default: false) {
|
||||
let printed = option.at("printed", default: ())
|
||||
let letter = if option.at("is-key", default: false) {
|
||||
text(weight: "bold", fill: ok-color)[#option.letter]
|
||||
} else { [#option.letter] }
|
||||
if printed.len() == 0 {
|
||||
letter
|
||||
} else {
|
||||
stack(
|
||||
dir: ttb,
|
||||
spacing: step * 0.25,
|
||||
letter,
|
||||
text(size: size-micro, fill: luma(125))[
|
||||
#printed.map(p => p.form + ":" + p.letter).join(" ")
|
||||
],
|
||||
)
|
||||
}
|
||||
},
|
||||
text(size: size-small)[#option.count],
|
||||
text(size: size-small)[#pct(option.rate)],
|
||||
@@ -1165,6 +1287,16 @@
|
||||
text(size: size-small)[#signed(r)]
|
||||
}
|
||||
},
|
||||
{
|
||||
let body = option.at("text", default: none)
|
||||
if body == none {
|
||||
text(size: size-micro, fill: thin-color)[not in the bank]
|
||||
} else if option.at("is-key", default: false) {
|
||||
text(size: size-small, fill: ok-color.darken(25%))[#markup(body)]
|
||||
} else {
|
||||
text(size: size-small)[#markup(body)]
|
||||
}
|
||||
},
|
||||
))
|
||||
.flatten(),
|
||||
))
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
// it. If you change one thing here, keep that.
|
||||
|
||||
#import "@preview/mitex:0.2.7": mi
|
||||
#import "@preview/whalogen:0.3.0": ce
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Data
|
||||
@@ -116,8 +117,8 @@
|
||||
(
|
||||
number: 1,
|
||||
level: 1,
|
||||
objectives: ("lo-sample-met",),
|
||||
objective-texts: (),
|
||||
targets: ("t-sample-met",),
|
||||
measured: (),
|
||||
correct: true,
|
||||
credit: 1.0,
|
||||
bonus: false,
|
||||
@@ -130,8 +131,11 @@
|
||||
(
|
||||
number: 2,
|
||||
level: 3,
|
||||
objectives: ("lo-sample-gap",),
|
||||
objective-texts: ([A sample objective to work on.],),
|
||||
targets: ("t-sample-gap",),
|
||||
measured: ((
|
||||
objective: [A sample objective to work on.],
|
||||
target: [A sample learning target under it.],
|
||||
),),
|
||||
correct: false,
|
||||
credit: 0.0,
|
||||
bonus: false,
|
||||
@@ -148,11 +152,11 @@
|
||||
(
|
||||
lecture: "L1.1",
|
||||
title: "Enthalpy",
|
||||
objectives-missed: 2,
|
||||
targets-missed: 2,
|
||||
questions-missed: 3,
|
||||
questions: (2, 14, 15),
|
||||
slides: (12, 13),
|
||||
objectives: ([A sample objective to work on.], [A second one from the same lecture.]),
|
||||
targets: ([A sample learning target to work on.], [A second one from the same lecture.]),
|
||||
),
|
||||
),
|
||||
study: (
|
||||
@@ -536,9 +540,9 @@
|
||||
= What the exam measured, objective by objective
|
||||
|
||||
#explain[
|
||||
Each line shows something the course asked you to do, just as it appears in the syllabus. *Q* tells you how many questions measured that skill. *You* shows the share you got right, and *class* shows the same for everyone else.
|
||||
Each line is one learning objective, just as it appears in the syllabus. *Q* counts every question on this exam that measured any part of it, so a line usually rests on several questions rather than one. *You* shows the share you got right, and *class* shows the same for everyone else.
|
||||
|
||||
The symbol in the first column is a summary. The mark you should focus on is #text(fill: thin-color, weight: "bold")[?], which means there were not enough questions to draw any conclusions about that line. On exams with many objectives, most lines will have this mark, since one question cannot show if you really know something or just guessed. These lines are not good or bad news. Instead, look for groups of lines that point in the same direction, and check the next two sections, which organize them for you.
|
||||
The symbol in the first column is the summary. A #text(fill: thin-color, weight: "bold")[?] means this exam did not ask enough about that objective to say anything either way, which is a fact about the exam and not about you. Nothing on this page is broken down question by question; for that, see which lectures to go back to, and the notes on the ones you missed.
|
||||
]
|
||||
|
||||
// The objective text is the only thing in this table that wants width, so it
|
||||
@@ -657,10 +661,10 @@
|
||||
// body is a stack, so the title, the provenance line, and the objectives are one
|
||||
// step apart and no paragraph contributes spacing of its own.
|
||||
#let lecture-entry(index, lecture) = {
|
||||
let count = lecture.at("objectives-missed", default: 0)
|
||||
let count = lecture.at("targets-missed", default: 0)
|
||||
let numbers = lecture.at("questions", default: ())
|
||||
let slides = lecture.at("slides", default: ())
|
||||
let objectives = lecture.at("objectives", default: ())
|
||||
let targets = lecture.at("targets", default: ())
|
||||
|
||||
let parts = ()
|
||||
|
||||
@@ -689,10 +693,14 @@
|
||||
parts.push(text(size: size-meta, fill: luma(115))[#trail.join(" · ")])
|
||||
}
|
||||
|
||||
// The learning targets, not the objectives they belong to. This section
|
||||
// answers "what do I go and restudy", and a target is the grain that can be
|
||||
// acted on: it names one performance rather than a whole claim.
|
||||
//
|
||||
// A real list rather than a middot glued to the front of a paragraph, so the
|
||||
// second line of a long objective indents under the first instead of running
|
||||
// second line of a long target indents under the first instead of running
|
||||
// back to the margin.
|
||||
if objectives.len() > 0 {
|
||||
if targets.len() > 0 {
|
||||
parts.push({
|
||||
set text(size: size-small, fill: luma(80))
|
||||
list(
|
||||
@@ -700,7 +708,7 @@
|
||||
body-indent: 0.45em,
|
||||
spacing: step * 0.7,
|
||||
marker: text(fill: luma(165))[·],
|
||||
..objectives.map(objective => markup(objective)),
|
||||
..targets.map(target => markup(target)),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -713,7 +721,7 @@
|
||||
rank(index + 1, if index == 0 { bad-color } else if count > 1 { mid-color } else { accent }),
|
||||
pad(right: prose-pad-inset)[#stack(dir: ttb, spacing: step, ..parts)],
|
||||
text(size: size-micro, fill: luma(105))[
|
||||
#count #plural(count, "objective", "objectives")
|
||||
#count #plural(count, "target", "targets")
|
||||
],
|
||||
)
|
||||
]
|
||||
@@ -726,7 +734,7 @@
|
||||
= Which lectures to go back to
|
||||
|
||||
#explain[
|
||||
Each question connects to the lecture it came from. If you sort the lectures by how many different objectives were missed, you get a clear order to review, starting with the most challenging. When one lecture covers several objectives, it often means an early idea was unclear, and fixing that is usually the easiest way to help.
|
||||
Each question connects to the lecture it came from. Sorting the lectures by how many separate things went wrong in each gives you an order to work through, hardest first. Under each lecture are the specific skills the questions were testing, so you can go to the part of it you need rather than rewatching the whole thing. Several from one lecture usually means an early idea did not land, and fixing that one is the cheapest repair.
|
||||
]
|
||||
|
||||
#for (index, lecture) in review-lectures.enumerate() [
|
||||
@@ -855,11 +863,37 @@
|
||||
#let note-entry(q) = {
|
||||
let parts = ()
|
||||
|
||||
let texts = q.at("objective-texts", default: ())
|
||||
if texts.len() > 0 {
|
||||
parts.push(text(fill: luma(21.57%))[
|
||||
#text(weight: "bold")[Measuring:] #texts.map(t => markup(t)).join([; ])
|
||||
])
|
||||
// Both tiers. The target is what this question actually asked, which is the
|
||||
// thing to practise; the objective is the row it counted toward in the table
|
||||
// above, which is how a student tells whether one slip cost them a claim. The
|
||||
// objective is absent when the two would be the same sentence.
|
||||
let measured = q.at("measured", default: ())
|
||||
if measured.len() > 0 {
|
||||
parts.push({
|
||||
set text(fill: luma(21.57%))
|
||||
stack(
|
||||
dir: ttb,
|
||||
spacing: step * 0.45,
|
||||
..measured.map(m => {
|
||||
let objective = m.at("objective", default: none)
|
||||
let target = [
|
||||
#text(weight: "bold")[This question asked you to:] #markup(m.target)
|
||||
]
|
||||
if objective == none {
|
||||
target
|
||||
} else {
|
||||
stack(
|
||||
dir: ttb,
|
||||
spacing: step * 0.3,
|
||||
target,
|
||||
text(size: size-small, fill: luma(110))[
|
||||
Counts toward: #markup(objective)
|
||||
],
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// The diagnosis carries full body size. It is the sentence worth reading
|
||||
|
||||
+17
-5
@@ -18,12 +18,19 @@
|
||||
//!
|
||||
//! | File | Holds | Written by |
|
||||
//! |:--|:--|:--|
|
||||
//! | `course.yaml` | identity, policy, objectives, lectures | you |
|
||||
//! | `course.yaml` | identity, policy, units | you |
|
||||
//! | `references.yaml` | the works the course cites | you |
|
||||
//! | `lectures/*.yaml` | one lecture: readings, and what it teaches | you |
|
||||
//! | `objectives/*.yaml` | one objective and its learning targets | 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.
|
||||
//! The first four are one course file split by subject; `coursebank course build`
|
||||
//! prints the merged result, and a course that keeps everything in `course.yaml`
|
||||
//! still loads unchanged. See [`course::fragment`].
|
||||
//!
|
||||
//! Most of these 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.
|
||||
//!
|
||||
@@ -95,12 +102,17 @@ pub mod data;
|
||||
pub mod error;
|
||||
pub mod export;
|
||||
pub mod guide;
|
||||
pub mod migrate;
|
||||
pub mod model;
|
||||
pub mod util;
|
||||
|
||||
pub use util::{date, hash, markup, rng, yaml, zipfile};
|
||||
pub use util::{citation, date, hash, markup, rng, yaml, zipfile};
|
||||
|
||||
pub use model::{assessment, bank, catalog, course, history, item, layout, seal, taxonomy};
|
||||
pub use model::{
|
||||
assessment, bank, calibration, catalog, course, history, item, layout, seal, taxonomy,
|
||||
};
|
||||
|
||||
pub use course::fragment;
|
||||
|
||||
pub use authoring::{jsonschema, lint, select};
|
||||
|
||||
@@ -110,7 +122,7 @@ pub use data::{canvas, decode, gradescope, intake, responses, store};
|
||||
pub use analysis::{calibrate, classical, diagnostic, irt, students};
|
||||
|
||||
pub use export::site;
|
||||
pub use export::{lecture, practice, qti, report, typst};
|
||||
pub use export::{lecture, practice, qti, references, report, typst};
|
||||
|
||||
pub use catalog::Catalog;
|
||||
pub use course::{CourseFile, SCHEMA_VERSION};
|
||||
|
||||
+2590
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -12,7 +12,9 @@
|
||||
//! ```text
|
||||
//! taxonomy levels, cognitive processes, error types, status, flags
|
||||
//! │
|
||||
//! course course.yaml: identity, policy, objectives, lectures, stimuli
|
||||
//! course identity, policy, objectives, lectures, stimuli
|
||||
//! │ └─ course::fragment merges course.yaml, references.yaml,
|
||||
//! │ lectures/*.yaml, objectives/*.yaml
|
||||
//! │
|
||||
//! item one question: stem, options, design intent, calibration
|
||||
//! │
|
||||
@@ -26,6 +28,7 @@
|
||||
|
||||
pub mod assessment;
|
||||
pub mod bank;
|
||||
pub mod calibration;
|
||||
pub mod catalog;
|
||||
pub mod course;
|
||||
pub mod history;
|
||||
|
||||
+102
-5
@@ -248,11 +248,28 @@ 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`.
|
||||
/// The item's id, which names it course-wide. A pre-2.0 `bank::item`
|
||||
/// value still resolves; `coursebank migrate ids` rewrites it.
|
||||
pub item: String,
|
||||
/// The item version used.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
/// Retained only so a pre-2.0 record still loads. Ignored.
|
||||
///
|
||||
/// What it was for — knowing whether the item has changed since this
|
||||
/// administration — is [`Placement::stem_digest`] and
|
||||
/// [`Placement::fingerprint`], which say *what* changed rather than that
|
||||
/// something did.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub version: Option<u32>,
|
||||
|
||||
/// The stem's digest as administered. See [`crate::item::Item::stem_digest`].
|
||||
///
|
||||
/// Distinct from `fingerprint`, which covers the options too. A changed
|
||||
/// fingerprint means the pooled statistics describe an older wording; a
|
||||
/// changed stem digest means this is no longer the same question, which
|
||||
/// [`crate::catalog::Catalog::validate_record`] treats as an error rather
|
||||
/// than a note.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stem_digest: Option<String>,
|
||||
/// The content fingerprint as used, so later edits are detectable.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fingerprint: Option<String>,
|
||||
@@ -265,12 +282,36 @@ pub struct Placement {
|
||||
/// The keyed letters as administered.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub key: Vec<String>,
|
||||
|
||||
/// The option ids offered alongside the key.
|
||||
///
|
||||
/// Resolved when the assessment is assembled and written out explicitly,
|
||||
/// never sampled at export time. A blueprint may ask for a draw; the record
|
||||
/// holds what was drawn. Otherwise a bank edit between assembling and
|
||||
/// printing silently changes the paper, and the key printed on Tuesday
|
||||
/// disagrees with the one printed on Wednesday.
|
||||
///
|
||||
/// Empty means the whole pool, which is what every pre-2.0 record meant.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub distractors: Vec<String>,
|
||||
|
||||
/// The digest of the item as this administration showed it.
|
||||
///
|
||||
/// See [`crate::item::Item::variant_digest`]. The key statistics pool on:
|
||||
/// two administrations of one stem with different distractors are two
|
||||
/// items, and averaging them is averaging different questions.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub variant: Option<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>,
|
||||
/// Targets as administered, denormalized for the same reason.
|
||||
#[serde(
|
||||
default,
|
||||
alias = "learning_objectives",
|
||||
skip_serializing_if = "Vec::is_empty"
|
||||
)]
|
||||
pub learning_targets: 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
|
||||
@@ -301,6 +342,28 @@ pub struct Placement {
|
||||
/// contribute.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dropped_as: Option<DropStyle>,
|
||||
/// Set only when the item was pulled *before* the paper was printed.
|
||||
///
|
||||
/// `dropped` on its own means what its own documentation says: the item was
|
||||
/// printed, students answered it, and it was then taken out of scoring.
|
||||
/// Such an item keeps its printed position, because it occupied one on the
|
||||
/// page the students held, and the responses that come back are numbered
|
||||
/// around it.
|
||||
///
|
||||
/// An item pulled before printing never occupied a position, so every later
|
||||
/// question moves up one. That case has to be distinguished, and it cannot
|
||||
/// be inferred: both look identical in the record. Getting it wrong is not
|
||||
/// a cosmetic error. Sealing a post-administration drop as if it had never
|
||||
/// been printed renumbers every question after it, so each response is
|
||||
/// attributed to the wrong item, the statistics for those items are
|
||||
/// computed from answers to different questions, and nothing in the output
|
||||
/// looks obviously wrong.
|
||||
///
|
||||
/// Practically: leave this alone when you discover a bad question after the
|
||||
/// exam, which is the common case. Set it when you cut a question from the
|
||||
/// draft and reprinted.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub dropped_before_printing: bool,
|
||||
}
|
||||
|
||||
/// How a dropped item was handled on the grading platform.
|
||||
@@ -314,6 +377,27 @@ pub enum DropStyle {
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
/// The variant this placement administered.
|
||||
///
|
||||
/// Recorded when the assessment was assembled; derived from the option set
|
||||
/// otherwise, which is what makes every record written before 2.0 poolable
|
||||
/// without being rewritten. A pre-2.0 placement names its key and no
|
||||
/// distractors, which means the whole pool — a well-defined option set, and
|
||||
/// so a well-defined variant.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `item` - the item this placement names.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The digest.
|
||||
pub fn variant_of(&self, item: &crate::item::Item) -> String {
|
||||
self.variant
|
||||
.clone()
|
||||
.unwrap_or_else(|| item.variant_digest(&self.key, &self.distractors))
|
||||
}
|
||||
|
||||
/// Whether this placement was dropped by crediting every option.
|
||||
///
|
||||
/// # Returns
|
||||
@@ -323,6 +407,19 @@ impl Placement {
|
||||
pub fn dropped_with_credit(&self) -> bool {
|
||||
self.dropped && self.dropped_as == Some(DropStyle::FullCredit)
|
||||
}
|
||||
|
||||
/// Whether this placement occupied a printed position on the paper.
|
||||
///
|
||||
/// Everything that lays out a page or reads a page back goes through this,
|
||||
/// so the printed form, the seal, and the decoder cannot disagree about
|
||||
/// which question sat where.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` unless the item was pulled before printing.
|
||||
pub fn was_printed(&self) -> bool {
|
||||
!(self.dropped && self.dropped_before_printing)
|
||||
}
|
||||
}
|
||||
|
||||
impl AssessmentFile {
|
||||
|
||||
+192
-111
@@ -79,7 +79,7 @@ pub struct BankMeta {
|
||||
|
||||
/// What a bank is scoped to.
|
||||
///
|
||||
/// A bank may be scoped by lecture, by objective, by topic, or by none of them.
|
||||
/// A bank may be scoped by lecture, by learning target, 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.
|
||||
@@ -89,9 +89,13 @@ 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>,
|
||||
/// Learning targets this bank is responsible for covering.
|
||||
#[serde(
|
||||
default,
|
||||
alias = "learning_objectives",
|
||||
skip_serializing_if = "Vec::is_empty"
|
||||
)]
|
||||
pub learning_targets: Vec<String>,
|
||||
/// Units this bank belongs to.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub units: Vec<String>,
|
||||
@@ -254,9 +258,9 @@ impl BankFile {
|
||||
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 target in &self.bank.scope.learning_targets {
|
||||
if !c.is_target(target) && !c.is_objective(target) {
|
||||
issues.push(format!("bank.scope: unknown learning target `{target}`"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -348,8 +352,10 @@ fn validate_item(
|
||||
if it.stem.trim().is_empty() {
|
||||
issues.push("empty stem".into());
|
||||
}
|
||||
if it.version == 0 {
|
||||
issues.push("version must be at least 1".into());
|
||||
if let Some(replaced) = &it.supersedes {
|
||||
if *replaced == it.id {
|
||||
issues.push("supersedes names this item".into());
|
||||
}
|
||||
}
|
||||
|
||||
// --- options ----
|
||||
@@ -375,21 +381,37 @@ fn validate_item(
|
||||
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 {
|
||||
// Two forms are accepted: the 2.0 name, and the letter that preceded
|
||||
// it. A bank migrates when `coursebank migrate options` is run on it,
|
||||
// not when the tool is upgraded, so a course mid-migration still loads.
|
||||
let slug_ok = o.id.strip_prefix("o-").is_some_and(|rest| {
|
||||
!rest.is_empty()
|
||||
&& !rest.starts_with('-')
|
||||
&& !rest.ends_with('-')
|
||||
&& !rest.contains("--")
|
||||
&& rest
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
});
|
||||
if !slug_ok && !Item::is_legacy_option_id(&o.id) {
|
||||
issues.push(format!(
|
||||
"option {pos}: id `{}` must be a single letter A through H",
|
||||
"option {pos}: id `{}` is neither a name such as `o-fourth-line` nor a pre-2.0 \
|
||||
letter A through H",
|
||||
o.id
|
||||
));
|
||||
}
|
||||
if seen.contains(&o.id.as_str()) {
|
||||
issues.push(format!("option {pos}: duplicate option id `{}`", o.id));
|
||||
}
|
||||
if let Some(retirement) = &o.retired {
|
||||
if retirement.reason.trim().is_empty() {
|
||||
issues.push(format!(
|
||||
"option {pos}: retired without a reason. The reason is the finding — what \
|
||||
the option did or failed to do — and it is the only part of a retirement \
|
||||
that is worth anything later."
|
||||
));
|
||||
}
|
||||
}
|
||||
seen.push(&o.id);
|
||||
|
||||
let credit = o.credit();
|
||||
@@ -429,14 +451,27 @@ fn validate_item(
|
||||
}
|
||||
|
||||
// --- key ---
|
||||
// Counted over the pool that can still be drawn: a retired option is a
|
||||
// record, not an offer.
|
||||
let (live_keys, live_distractors) = it.pool();
|
||||
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()
|
||||
));
|
||||
// Several defensible keys is a pool, not a bug — it is what lets you
|
||||
// test whether "fourth" or "last of the four" is doing the work.
|
||||
// Exactly one of them reaches a student, and that is the
|
||||
// placement's business: see
|
||||
// [`crate::catalog::Catalog::validate_record`].
|
||||
if live_keys.is_empty() {
|
||||
issues.push("single_best_answer needs at least one keyed option".into());
|
||||
}
|
||||
if live_distractors.is_empty() {
|
||||
let retired = it.options.iter().any(|o| o.retired.is_some());
|
||||
issues.push(if retired {
|
||||
"every distractor is retired, so nothing can be drawn against the key".into()
|
||||
} else {
|
||||
"has no option that is not keyed correct, so it asks nothing".to_string()
|
||||
});
|
||||
}
|
||||
}
|
||||
Format::MultipleResponse => {
|
||||
@@ -495,57 +530,12 @@ fn validate_item(
|
||||
}
|
||||
|
||||
// --- 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.keys() {
|
||||
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
|
||||
));
|
||||
if it.calibration.is_some() {
|
||||
issues.push(
|
||||
"has a `calibration:` block, but statistics live in analysis/calibration.yaml since \
|
||||
2.0. A bank's diff should be a change of intent, not the output of a grading run."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// --- retirement -----
|
||||
@@ -563,8 +553,8 @@ fn validate_item(
|
||||
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.learning_targets.is_empty() {
|
||||
issues.push("approved items must reference at least one learning target".into());
|
||||
}
|
||||
if it.sources.is_empty() {
|
||||
issues.push("approved items must cite at least one source".into());
|
||||
@@ -590,26 +580,64 @@ fn validate_item(
|
||||
|
||||
// --- 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 tag in &it.learning_targets {
|
||||
let is_target = c.is_target(tag);
|
||||
if !is_target && !c.is_objective(tag) {
|
||||
issues.push(format!("unknown learning target `{tag}`"));
|
||||
continue;
|
||||
}
|
||||
// Items are tagged at the target tier. Tagging an objective that has
|
||||
// targets would put the item in that objective's denominator without
|
||||
// recording which performance the question actually asked for, and
|
||||
// that record is what a report drills into and what coverage
|
||||
// analysis counts. An objective with no targets stands as its own.
|
||||
let its_targets = c.targets(tag);
|
||||
if !its_targets.is_empty() {
|
||||
issues.push(format!(
|
||||
"`{tag}` is an objective with {} learning target(s); tag the specific \
|
||||
target this item measures instead",
|
||||
its_targets.len()
|
||||
));
|
||||
}
|
||||
// The ceiling may be inherited from the objective, so a target that
|
||||
// declares none of its own is still bounded.
|
||||
if let Some(ceiling) = c.effective_level_ceiling(tag) {
|
||||
if it.level > ceiling {
|
||||
let declares_its_own = if is_target {
|
||||
c.learning_targets
|
||||
.get(tag)
|
||||
.is_some_and(|t| t.level_ceiling.is_some())
|
||||
} else {
|
||||
true
|
||||
};
|
||||
let source = if declares_its_own {
|
||||
format!("declared for `{tag}`")
|
||||
} else {
|
||||
format!(
|
||||
"inherited by `{tag}` from its objective `{}`",
|
||||
c.objective_for(tag)
|
||||
)
|
||||
};
|
||||
issues.push(format!(
|
||||
"level {} exceeds the ceiling {} {source}",
|
||||
it.level.code(),
|
||||
ceiling.code()
|
||||
));
|
||||
}
|
||||
}
|
||||
if !c.is_assessed(tag) {
|
||||
let parked_above =
|
||||
is_target && c.learning_targets.get(tag).is_some_and(|t| t.assessed);
|
||||
let which = if parked_above {
|
||||
format!(
|
||||
"its objective `{}` is marked `assessed: false`",
|
||||
c.objective_for(tag)
|
||||
)
|
||||
} else {
|
||||
format!("`{tag}` is marked `assessed: false`")
|
||||
};
|
||||
issues.push(format!("{which} but this item measures it"));
|
||||
}
|
||||
}
|
||||
for s in &it.sources {
|
||||
if !c.lectures.contains_key(&s.lecture) {
|
||||
@@ -707,7 +735,7 @@ mod tests {
|
||||
level: 2
|
||||
format: open_response
|
||||
cognitive_process: explain
|
||||
learning_objectives: [lo-x]
|
||||
learning_targets: [lo-x]
|
||||
sources: [{ lecture: L1.1 }]
|
||||
design: { rationale: r }
|
||||
stem: Explain the first law.
|
||||
@@ -748,20 +776,43 @@ mod tests {
|
||||
options:
|
||||
- { id: A, text: a, correct: true }
|
||||
- { id: B, text: b, correct: true }
|
||||
- id: q-a-003
|
||||
status: draft
|
||||
level: 1
|
||||
format: single_best_answer
|
||||
stem: s
|
||||
options:
|
||||
- { id: o-key-one, text: a, correct: true }
|
||||
- { id: o-key-two, text: b, correct: true }
|
||||
- { id: o-wrong-one, text: c }
|
||||
- { id: o-wrong-two, text: d }
|
||||
"#,
|
||||
);
|
||||
let issues = b.validate(None);
|
||||
// No key at all is still a bank problem: nothing can be drawn from it.
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|i| i.contains("exactly one keyed option"))
|
||||
.any(|i| i.starts_with("q-a-001") && i.contains("at least one keyed option")),
|
||||
"{issues:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
// Keying every option is still a bank problem, for the older reason: a
|
||||
// question with nothing to choose against asks nothing.
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.filter(|i| i.contains("exactly one keyed option"))
|
||||
.count(),
|
||||
2
|
||||
.any(|i| i.starts_with("q-a-002") && i.contains("asks nothing")),
|
||||
"{issues:?}"
|
||||
);
|
||||
// Two defensible keys alongside real distractors is not a problem. It
|
||||
// is the pool doing its job — it is what lets you test whether the
|
||||
// wording of the key is what students are answering. Exactly one of
|
||||
// them reaches a student, and that is checked against the placement
|
||||
// that administers it, in `Catalog::validate_placement`.
|
||||
assert!(
|
||||
!issues.iter().any(|i| i.starts_with("q-a-003")
|
||||
&& (i.contains("keyed option") || i.contains("asks nothing"))),
|
||||
"{issues:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -827,7 +878,7 @@ mod tests {
|
||||
let issues = b.validate(None);
|
||||
for want in [
|
||||
"cognitive_process",
|
||||
"learning objective",
|
||||
"learning target",
|
||||
"source",
|
||||
"design block",
|
||||
] {
|
||||
@@ -914,7 +965,7 @@ learning_objectives:
|
||||
status: draft
|
||||
level: 4
|
||||
stem: s
|
||||
learning_objectives: [lo-known, lo-unknown]
|
||||
learning_targets: [lo-known, lo-unknown]
|
||||
sources: [{ lecture: L99 }]
|
||||
options:
|
||||
- { id: A, text: a, correct: true }
|
||||
@@ -925,7 +976,7 @@ learning_objectives:
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|i| i.contains("unknown learning objective `lo-unknown`"))
|
||||
.any(|i| i.contains("unknown learning target `lo-unknown`"))
|
||||
);
|
||||
assert!(issues.iter().any(|i| i.contains("unknown lecture `L99`")));
|
||||
assert!(
|
||||
@@ -935,24 +986,54 @@ learning_objectives:
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_versions_must_increase() {
|
||||
fn an_item_must_tag_a_target_not_an_objective_that_has_them() {
|
||||
let course: CourseFile = serde_yaml_ng::from_str(
|
||||
r#"
|
||||
course: { code: X, title: Y, term: Z }
|
||||
learning_objectives:
|
||||
lo-binding: { text: Quantify binding., level_ceiling: 3 }
|
||||
lo-solo: { text: An objective with no targets. }
|
||||
learning_targets:
|
||||
t-kd: { text: Write the expression., objective: lo-binding }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let b = bank(
|
||||
r#"
|
||||
- id: q-a-001
|
||||
version: 2
|
||||
status: draft
|
||||
level: 1
|
||||
stem: s
|
||||
learning_targets: [lo-binding]
|
||||
options:
|
||||
- { id: A, text: a, correct: true }
|
||||
- { id: B, text: b }
|
||||
- id: q-a-002
|
||||
status: draft
|
||||
level: 1
|
||||
stem: s
|
||||
learning_targets: [t-kd, lo-solo]
|
||||
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")));
|
||||
let issues = b.validate(Some(&course));
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|i| i.contains("`lo-binding` is an objective with 1 learning target(s)")),
|
||||
"got {issues:?}"
|
||||
);
|
||||
// A target is fine, and so is an objective that has no targets: it
|
||||
// stands as its own, which is what lets a course migrate a unit at a
|
||||
// time.
|
||||
assert!(
|
||||
!issues
|
||||
.iter()
|
||||
.any(|i| i.contains("`t-kd`") || i.contains("`lo-solo`")),
|
||||
"got {issues:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -964,7 +1045,7 @@ learning_objectives:
|
||||
level: 1
|
||||
cognitive_process: recall
|
||||
stem: s
|
||||
learning_objectives: [lo]
|
||||
learning_targets: [lo]
|
||||
sources: [{ lecture: L1 }]
|
||||
design: { expected_difficulty: 0.8 }
|
||||
options:
|
||||
@@ -983,7 +1064,7 @@ learning_objectives:
|
||||
cognitive_process: generate
|
||||
bonus: true
|
||||
stem: s
|
||||
learning_objectives: [lo]
|
||||
learning_targets: [lo]
|
||||
sources: [{ lecture: L1 }]
|
||||
design: { expected_difficulty: 0.3 }
|
||||
options:
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! Where the statistics live, which is not in the bank.
|
||||
//!
|
||||
//! A bank file is a reviewed artifact: someone wrote the question, someone
|
||||
//! argued about the distractors, and the diff on it should be a change of
|
||||
//! intent. Statistics are neither reviewed nor intended — they are what
|
||||
//! happened — and writing them back into the bank means every grading run
|
||||
//! produces a diff on a file whose history is supposed to be about wording.
|
||||
//!
|
||||
//! So the evidence lives in `analysis/`, in two kinds of file:
|
||||
//!
|
||||
//! | File | Format | Rewritten? | Holds |
|
||||
//! |:--|:--|:--|:--|
|
||||
//! | `analysis/administrations/<id>-items.csv` | CSV | never | one row per question |
|
||||
//! | `analysis/administrations/<id>-options.csv` | CSV | never | one row per question and option |
|
||||
//! | `analysis/calibration.yaml` | YAML | by `calibrate` | the pooled per-item view |
|
||||
//!
|
||||
//! The format follows the shape. An administration record is a table — fixed
|
||||
//! columns, one row per question, machine-written, never hand-edited — so it is
|
||||
//! CSV: one line per item rather than fifteen, which diffs better, and it loads
|
||||
//! straight into pandas or DuckDB, which is much of the point of committing it.
|
||||
//! The pooled view is not a table. It is three levels deep, item to variant to
|
||||
//! option history, with fitted parameters and variable-length lists, and as CSV
|
||||
//! that would be three files joined by keys — a relational schema for the one
|
||||
//! file a person actually reads in a pull request. That stays YAML.
|
||||
//!
|
||||
//! Neither is Parquet, and the reason is the review workflow: a binary file
|
||||
//! shows nothing in a diff and cannot be merged. Parquet is right for `data/`
|
||||
//! precisely because that is bulk, ignored, and never reviewed.
|
||||
//!
|
||||
//! An administration file is written once and not touched again, for the same
|
||||
//! reason a seal is not: it is a record of a thing that happened on a day. The
|
||||
//! calibration file is the accepted rollup — what `lint` compares your
|
||||
//! predictions against, and what a report reads — and `calibrate` proposes
|
||||
//! changes to it as a diff you review before committing.
|
||||
//!
|
||||
//! Both are meant to be committed. Neither can carry student data, and that is
|
||||
//! a property of the types rather than a promise: there is no field for a
|
||||
//! student key, an identifier, a section, or an ability estimate, and the
|
||||
//! structures reject unknown keys, so a file carrying one fails to load rather
|
||||
//! than being quietly accepted. Everything per-person stays in `data/`, which
|
||||
//! is what your `.gitignore` is for.
|
||||
//!
|
||||
//! # Linking back to the bank
|
||||
//!
|
||||
//! By item id, which since 2.0 names the item course-wide and has no file name
|
||||
//! in it, and by variant digest, which says which option set the numbers
|
||||
//! describe. A record also carries the stem digest it was measured against, so
|
||||
//! [`CalibrationFile::validate`] can say that an item has been reworded since —
|
||||
//! the statistics then describe a question that no longer exists under that id.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::course::SCHEMA_VERSION;
|
||||
use crate::date::Date;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::item::{Calibration, IrtModel, IrtParams, OptionStat};
|
||||
use crate::taxonomy::Flag;
|
||||
use crate::yaml;
|
||||
|
||||
/// The file name of the pooled calibration store, under `analysis/`.
|
||||
pub const CALIBRATION_FILE: &str = "calibration.yaml";
|
||||
|
||||
/// The pooled per-item statistics: `analysis/calibration.yaml`.
|
||||
///
|
||||
/// Keyed by item id. This is the file `calibrate` rewrites and the one
|
||||
/// everything else reads; [`crate::catalog::Catalog::load`] fills each item's
|
||||
/// in-memory calibration from it, so nothing downstream has to know where the
|
||||
/// numbers came from.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CalibrationFile {
|
||||
/// Schema version.
|
||||
#[serde(
|
||||
default = "default_version",
|
||||
deserialize_with = "yaml::flexible_string"
|
||||
)]
|
||||
pub schema_version: String,
|
||||
|
||||
/// One entry per calibrated item, by item id.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub items: BTreeMap<String, Calibration>,
|
||||
}
|
||||
|
||||
impl CalibrationFile {
|
||||
/// Loads the store, or an empty one when the file does not exist yet.
|
||||
///
|
||||
/// Absence is not an error: a course that has not graded anything has no
|
||||
/// statistics, and every command that reads them has to work anyway.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - the file, usually `analysis/calibration.yaml`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The store.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Yaml`] when the file exists and does not parse.
|
||||
pub fn load(path: &Path) -> Result<CalibrationFile> {
|
||||
if !path.is_file() {
|
||||
return Ok(CalibrationFile::default());
|
||||
}
|
||||
yaml::read(path)
|
||||
}
|
||||
|
||||
/// Writes the store.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - the destination.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Io`] on a write failure.
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
yaml::write(path, self)
|
||||
}
|
||||
|
||||
/// The calibration recorded for one item.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `item` - the item id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The record, or `None` when the item has never been calibrated.
|
||||
pub fn get(&self, item: &str) -> Option<&Calibration> {
|
||||
self.items.get(item)
|
||||
}
|
||||
|
||||
/// Checks the store against the bank it describes.
|
||||
///
|
||||
/// The checks that matter for a file kept apart from what it refers to: a
|
||||
/// record for an item that no longer exists, an option id the item does not
|
||||
/// have, and — the one worth having — statistics measured against a stem
|
||||
/// that has since been reworded, which since 2.0 means they describe a
|
||||
/// different question wearing the same id.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `catalog` - the loaded course.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// One message per problem, empty when the store agrees with the bank.
|
||||
pub fn validate(&self, catalog: &crate::catalog::Catalog) -> Vec<String> {
|
||||
let mut issues = Vec::new();
|
||||
|
||||
for (id, calibration) in &self.items {
|
||||
let Some(entry) = catalog.get(id) else {
|
||||
issues.push(format!(
|
||||
"calibration for `{id}`: no such item. Statistics outlive an item only if \
|
||||
it is retired, not deleted — a retired item keeps its id so its numbers \
|
||||
still mean something."
|
||||
));
|
||||
continue;
|
||||
};
|
||||
let item = &entry.item;
|
||||
|
||||
for variant in &calibration.variants {
|
||||
for option in variant.option_stats.keys() {
|
||||
if item.option(option).is_none() {
|
||||
issues.push(format!(
|
||||
"calibration for `{id}`: variant `{}` has statistics for `{option}`, \
|
||||
which is not an option of this item",
|
||||
short(&variant.variant)
|
||||
));
|
||||
}
|
||||
}
|
||||
if !variant.key.is_empty()
|
||||
&& variant.variant != item.variant_digest(&variant.key, &variant.distractors)
|
||||
{
|
||||
issues.push(format!(
|
||||
"calibration for `{id}`: variant `{}` was measured against an option set \
|
||||
that has since been reworded, so its numbers describe wording no \
|
||||
student now sees",
|
||||
short(&variant.variant)
|
||||
));
|
||||
}
|
||||
}
|
||||
for option in calibration.options.keys() {
|
||||
if item.option(option).is_none() {
|
||||
issues.push(format!(
|
||||
"calibration for `{id}`: an option history names `{option}`, which is \
|
||||
not an option of this item"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
issues
|
||||
}
|
||||
}
|
||||
|
||||
/// What one administration measured: `analysis/administrations/<id>.yaml`.
|
||||
///
|
||||
/// Written once, when the exam is analyzed, and never rewritten. It is the
|
||||
/// audit trail under [`CalibrationFile`]: the pooled numbers say an item sits
|
||||
/// at 0.63, and these say which exams that came from and what each one saw.
|
||||
/// Keeping them also means the history survives losing `data/`, which is
|
||||
/// ignored by git and rotates.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MeasurementFile {
|
||||
/// Schema version.
|
||||
#[serde(
|
||||
default = "default_version",
|
||||
deserialize_with = "yaml::flexible_string"
|
||||
)]
|
||||
pub schema_version: String,
|
||||
|
||||
/// What was administered, and how it was analyzed.
|
||||
pub administration: MeasurementMeta,
|
||||
|
||||
/// One entry per question, in the order it was printed.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub items: Vec<Measurement>,
|
||||
}
|
||||
|
||||
/// What an administration was, for a reader two years later.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MeasurementMeta {
|
||||
/// The administration id these numbers came from.
|
||||
pub id: String,
|
||||
/// The assessment that was administered.
|
||||
pub assessment: String,
|
||||
/// The term.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub term: Option<String>,
|
||||
/// The date it was given.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub date: Option<Date>,
|
||||
/// The forms in play.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub forms: Vec<String>,
|
||||
/// How many examinees the numbers pool over.
|
||||
///
|
||||
/// The one number to read before any of the others. A point-biserial on
|
||||
/// twenty-seven students is a different kind of claim than one on three
|
||||
/// hundred, and nothing below records how thin it is.
|
||||
pub n_examinees: usize,
|
||||
/// The item response model fitted, when one was.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<IrtModel>,
|
||||
/// When the analysis was run.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub generated: Option<Date>,
|
||||
/// The version of the tool that ran it, since the numbers depend on it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub coursebank: Option<String>,
|
||||
}
|
||||
|
||||
/// One question's statistics from one administration.
|
||||
///
|
||||
/// Cohort aggregates only. There is deliberately no per-section or per-form
|
||||
/// breakdown: those get small, and a small cell crossed with anything else is
|
||||
/// how an aggregate stops being one.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Measurement {
|
||||
/// The item id.
|
||||
pub item: String,
|
||||
/// The question number it was printed as.
|
||||
pub number: u32,
|
||||
/// The option set administered. See [`crate::item::Item::variant_digest`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub variant: Option<String>,
|
||||
/// The stem as administered. See [`crate::item::Item::stem_digest`].
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stem_digest: Option<String>,
|
||||
/// Examinees who saw it.
|
||||
pub n: 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>,
|
||||
/// The option ids keyed correct, so a row in the options file says whether
|
||||
/// it describes the answer or a distractor without a join.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub key: Vec<String>,
|
||||
/// Per-option behaviour, by option id.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub option_stats: BTreeMap<String, OptionStat>,
|
||||
/// Fitted parameters, when the sample supported a fit.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub irt: Option<IrtParams>,
|
||||
/// Machine-detected problems with this question on this administration.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub flags: Vec<Flag>,
|
||||
}
|
||||
|
||||
/// The columns of an items file, and the only ones accepted on read.
|
||||
///
|
||||
/// An allowlist rather than a type-level guarantee. In YAML the structures
|
||||
/// reject unknown keys, so a file carrying a student column could not be
|
||||
/// loaded; CSV readers are tolerant of extra columns, so the same assurance has
|
||||
/// to be an explicit check. This is it, and [`MeasurementFile::read_csv`]
|
||||
/// refuses any header not named here.
|
||||
pub const ITEM_COLUMNS: [&str; 16] = [
|
||||
"administration_id",
|
||||
"assessment",
|
||||
"term",
|
||||
"date",
|
||||
"forms",
|
||||
"n_examinees",
|
||||
"coursebank",
|
||||
"generated",
|
||||
"item",
|
||||
"number",
|
||||
"variant",
|
||||
"stem_digest",
|
||||
"n",
|
||||
"p_value",
|
||||
"point_biserial",
|
||||
"discrimination_index",
|
||||
];
|
||||
|
||||
/// The columns of an options file, and the only ones accepted on read.
|
||||
pub const OPTION_COLUMNS: [&str; 9] = [
|
||||
"administration_id",
|
||||
"item",
|
||||
"number",
|
||||
"option",
|
||||
"keyed",
|
||||
"selection_rate",
|
||||
"point_biserial",
|
||||
"upper_group_rate",
|
||||
"lower_group_rate",
|
||||
];
|
||||
|
||||
/// One row of an items file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ItemRow {
|
||||
administration_id: String,
|
||||
assessment: String,
|
||||
term: String,
|
||||
date: String,
|
||||
forms: String,
|
||||
n_examinees: usize,
|
||||
coursebank: String,
|
||||
generated: String,
|
||||
item: String,
|
||||
number: u32,
|
||||
variant: String,
|
||||
stem_digest: String,
|
||||
n: usize,
|
||||
p_value: String,
|
||||
point_biserial: String,
|
||||
discrimination_index: String,
|
||||
}
|
||||
|
||||
/// One row of an options file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct OptionRow {
|
||||
administration_id: String,
|
||||
item: String,
|
||||
number: u32,
|
||||
option: String,
|
||||
keyed: bool,
|
||||
selection_rate: String,
|
||||
point_biserial: String,
|
||||
upper_group_rate: String,
|
||||
lower_group_rate: String,
|
||||
}
|
||||
|
||||
impl MeasurementFile {
|
||||
/// The two file names this administration writes, items first.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `dir` - usually `analysis/administrations`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The items path and the options path.
|
||||
pub fn paths(&self, dir: &Path) -> (PathBuf, PathBuf) {
|
||||
let stem = crate::course::slugify(&self.administration.id);
|
||||
(
|
||||
dir.join(format!("{stem}-items.csv")),
|
||||
dir.join(format!("{stem}-options.csv")),
|
||||
)
|
||||
}
|
||||
|
||||
/// Writes the two files, refusing to overwrite either.
|
||||
///
|
||||
/// An administration is a thing that happened once, so replacing its record
|
||||
/// is a deliberate act: delete the files first if you mean to re-analyze.
|
||||
///
|
||||
/// Two files rather than one because the data is two shapes — one row per
|
||||
/// question, one row per question and option — and a single sparse table
|
||||
/// serves neither. The administration's metadata repeats on every row,
|
||||
/// which is what makes each file independently loadable and is the same
|
||||
/// convention the response store already uses.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `dir` - the directory to write into.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The paths written.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Usage`] when either file exists, and [`Error::Io`] or
|
||||
/// [`Error::Csv`] on a write failure.
|
||||
pub fn write_csv(&self, dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
let (items_path, options_path) = self.paths(dir);
|
||||
for path in [&items_path, &options_path] {
|
||||
if path.exists() {
|
||||
return Err(Error::usage(format!(
|
||||
"{} already records this administration. It happened once, so replacing it \
|
||||
is a deliberate act: delete it first if you mean to re-analyze.",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
|
||||
|
||||
let meta = &self.administration;
|
||||
let mut items = csv::Writer::from_path(&items_path).map_err(|e| Error::Csv {
|
||||
path: items_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
let mut options = csv::Writer::from_path(&options_path).map_err(|e| Error::Csv {
|
||||
path: options_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
for measurement in &self.items {
|
||||
items
|
||||
.serialize(ItemRow {
|
||||
administration_id: meta.id.clone(),
|
||||
assessment: meta.assessment.clone(),
|
||||
term: meta.term.clone().unwrap_or_default(),
|
||||
date: meta.date.map(|d| d.to_string()).unwrap_or_default(),
|
||||
forms: meta.forms.join(";"),
|
||||
n_examinees: meta.n_examinees,
|
||||
coursebank: meta.coursebank.clone().unwrap_or_default(),
|
||||
generated: meta.generated.map(|d| d.to_string()).unwrap_or_default(),
|
||||
item: measurement.item.clone(),
|
||||
number: measurement.number,
|
||||
variant: measurement.variant.clone().unwrap_or_default(),
|
||||
stem_digest: measurement.stem_digest.clone().unwrap_or_default(),
|
||||
n: measurement.n,
|
||||
p_value: number(measurement.p_value),
|
||||
point_biserial: number(measurement.point_biserial),
|
||||
discrimination_index: number(measurement.discrimination_index),
|
||||
})
|
||||
.map_err(|e| Error::Csv {
|
||||
path: items_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
for (option, stat) in &measurement.option_stats {
|
||||
options
|
||||
.serialize(OptionRow {
|
||||
administration_id: meta.id.clone(),
|
||||
item: measurement.item.clone(),
|
||||
number: measurement.number,
|
||||
option: option.clone(),
|
||||
keyed: measurement.key.iter().any(|k| k == option),
|
||||
selection_rate: number(stat.selection_rate),
|
||||
point_biserial: number(stat.point_biserial),
|
||||
upper_group_rate: number(stat.upper_group_rate),
|
||||
lower_group_rate: number(stat.lower_group_rate),
|
||||
})
|
||||
.map_err(|e| Error::Csv {
|
||||
path: options_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
items.flush().map_err(|e| Error::io(&items_path, e))?;
|
||||
options.flush().map_err(|e| Error::io(&options_path, e))?;
|
||||
Ok(vec![items_path, options_path])
|
||||
}
|
||||
|
||||
/// Reads one administration back from its two files.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `items_path` - the items file. The options file is found beside it.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The administration, with its per-option statistics reattached.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Csv`] on a parse failure and [`Error::Invalid`] when a
|
||||
/// file carries a column that is not in [`ITEM_COLUMNS`] or
|
||||
/// [`OPTION_COLUMNS`] — which is how a student column is caught.
|
||||
pub fn read_csv(items_path: &Path) -> Result<MeasurementFile> {
|
||||
let options_path = PathBuf::from(
|
||||
items_path
|
||||
.to_string_lossy()
|
||||
.replace("-items.csv", "-options.csv"),
|
||||
);
|
||||
|
||||
let mut reader = open_csv(items_path, &ITEM_COLUMNS)?;
|
||||
let mut meta = MeasurementMeta::default();
|
||||
let mut items: Vec<Measurement> = Vec::new();
|
||||
for row in reader.deserialize::<ItemRow>() {
|
||||
let row = row.map_err(|e| Error::Csv {
|
||||
path: items_path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
meta = MeasurementMeta {
|
||||
id: row.administration_id.clone(),
|
||||
assessment: row.assessment.clone(),
|
||||
term: some(&row.term),
|
||||
date: parse_date(&row.date),
|
||||
forms: row
|
||||
.forms
|
||||
.split(';')
|
||||
.filter(|f| !f.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
n_examinees: row.n_examinees,
|
||||
model: meta.model,
|
||||
generated: parse_date(&row.generated),
|
||||
coursebank: some(&row.coursebank),
|
||||
};
|
||||
items.push(Measurement {
|
||||
item: row.item,
|
||||
number: row.number,
|
||||
variant: some(&row.variant),
|
||||
stem_digest: some(&row.stem_digest),
|
||||
n: row.n,
|
||||
p_value: parse(&row.p_value),
|
||||
point_biserial: parse(&row.point_biserial),
|
||||
discrimination_index: parse(&row.discrimination_index),
|
||||
..Measurement::default()
|
||||
});
|
||||
}
|
||||
|
||||
if options_path.is_file() {
|
||||
let mut reader = open_csv(&options_path, &OPTION_COLUMNS)?;
|
||||
for row in reader.deserialize::<OptionRow>() {
|
||||
let row = row.map_err(|e| Error::Csv {
|
||||
path: options_path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
let Some(target) = items.iter_mut().find(|i| i.number == row.number) else {
|
||||
continue;
|
||||
};
|
||||
if row.keyed && !target.key.contains(&row.option) {
|
||||
target.key.push(row.option.clone());
|
||||
}
|
||||
target.option_stats.insert(
|
||||
row.option,
|
||||
OptionStat {
|
||||
selection_rate: parse(&row.selection_rate),
|
||||
point_biserial: parse(&row.point_biserial),
|
||||
upper_group_rate: parse(&row.upper_group_rate),
|
||||
lower_group_rate: parse(&row.lower_group_rate),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(MeasurementFile {
|
||||
schema_version: default_version(),
|
||||
administration: meta,
|
||||
items,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads every administration under a directory, oldest first.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `dir` - usually `analysis/administrations`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The administrations, empty when the directory does not exist.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates read failures.
|
||||
pub fn load_all(dir: &Path) -> Result<Vec<MeasurementFile>> {
|
||||
if !dir.is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
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.to_string_lossy().ends_with("-items.csv"))
|
||||
.collect();
|
||||
paths.sort();
|
||||
|
||||
let mut out = Vec::new();
|
||||
for path in &paths {
|
||||
out.push(MeasurementFile::read_csv(path)?);
|
||||
}
|
||||
out.sort_by(|a, b| {
|
||||
a.administration
|
||||
.date
|
||||
.cmp(&b.administration.date)
|
||||
.then(a.administration.id.cmp(&b.administration.id))
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a CSV and refuses any column that is not on the allowlist.
|
||||
fn open_csv(path: &Path, allowed: &[&str]) -> Result<csv::Reader<std::fs::File>> {
|
||||
let mut reader = csv::Reader::from_path(path).map_err(|e| Error::Csv {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
let headers = reader
|
||||
.headers()
|
||||
.map_err(|e| Error::Csv {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let unexpected: Vec<&str> = headers.iter().filter(|h| !allowed.contains(h)).collect();
|
||||
if !unexpected.is_empty() {
|
||||
return Err(Error::Invalid(vec![format!(
|
||||
"{}: unexpected column(s) {}. These files are committed, so they hold cohort \
|
||||
aggregates and nothing else — anything per-student belongs in data/, which is \
|
||||
ignored.",
|
||||
path.display(),
|
||||
unexpected.join(", ")
|
||||
)]));
|
||||
}
|
||||
Ok(reader)
|
||||
}
|
||||
|
||||
/// A float as a CSV cell, empty when absent.
|
||||
fn number(value: Option<f64>) -> String {
|
||||
value.map(|v| format!("{v}")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A CSV cell as a float, absent when empty or unparseable.
|
||||
fn parse(cell: &str) -> Option<f64> {
|
||||
cell.trim().parse().ok()
|
||||
}
|
||||
|
||||
/// A CSV cell as a string, absent when empty.
|
||||
fn some(cell: &str) -> Option<String> {
|
||||
(!cell.trim().is_empty()).then(|| cell.trim().to_string())
|
||||
}
|
||||
|
||||
/// A CSV cell as a date, absent when empty or unparseable.
|
||||
fn parse_date(cell: &str) -> Option<Date> {
|
||||
some(cell).and_then(|c| c.parse().ok())
|
||||
}
|
||||
|
||||
/// The schema version new files are written with.
|
||||
fn default_version() -> String {
|
||||
SCHEMA_VERSION.to_string()
|
||||
}
|
||||
|
||||
/// A digest shortened for a message.
|
||||
fn short(digest: &str) -> String {
|
||||
digest.chars().take(8).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::item::VariantCalibration;
|
||||
|
||||
fn tmp(tag: &str) -> std::path::PathBuf {
|
||||
let p = std::env::temp_dir().join(format!("coursebank-cal-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&p);
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absent_store_is_empty_rather_than_an_error() {
|
||||
let dir = tmp("absent");
|
||||
let store = CalibrationFile::load(&dir.join(CALIBRATION_FILE)).unwrap();
|
||||
assert!(store.items.is_empty());
|
||||
assert!(store.get("q-x").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_store_round_trips() {
|
||||
let dir = tmp("round");
|
||||
let path = dir.join(CALIBRATION_FILE);
|
||||
|
||||
let mut store = CalibrationFile::default();
|
||||
store.items.insert(
|
||||
"q-x".to_string(),
|
||||
Calibration {
|
||||
n_examinees: Some(27),
|
||||
p_value: Some(0.63),
|
||||
variants: vec![VariantCalibration {
|
||||
variant: "4c81fa".into(),
|
||||
n_examinees: Some(27),
|
||||
..VariantCalibration::default()
|
||||
}],
|
||||
..Calibration::default()
|
||||
},
|
||||
);
|
||||
store.save(&path).unwrap();
|
||||
|
||||
let back = CalibrationFile::load(&path).unwrap();
|
||||
assert_eq!(back.get("q-x").unwrap().p_value, Some(0.63));
|
||||
assert_eq!(back.get("q-x").unwrap().variants.len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_administration_round_trips_through_two_csvs() {
|
||||
let dir = tmp("csv");
|
||||
let file = MeasurementFile {
|
||||
schema_version: default_version(),
|
||||
administration: MeasurementMeta {
|
||||
id: "e1-2026f".into(),
|
||||
assessment: "e1".into(),
|
||||
term: Some("2026f".into()),
|
||||
date: Some(Date::new(2026, 9, 15).unwrap()),
|
||||
forms: vec!["A".into(), "B".into()],
|
||||
n_examinees: 27,
|
||||
model: None,
|
||||
generated: Some(Date::new(2026, 9, 27).unwrap()),
|
||||
coursebank: Some("0.0.0".into()),
|
||||
},
|
||||
items: vec![Measurement {
|
||||
item: "q-fastq-quality-length-match".into(),
|
||||
number: 1,
|
||||
variant: Some("237d62f9af222f78".into()),
|
||||
stem_digest: Some("8b22e0".into()),
|
||||
n: 27,
|
||||
p_value: Some(0.5926),
|
||||
point_biserial: Some(0.31),
|
||||
discrimination_index: None,
|
||||
key: vec!["o-fourth-line".into()],
|
||||
option_stats: [
|
||||
(
|
||||
"o-fourth-line".to_string(),
|
||||
OptionStat {
|
||||
selection_rate: Some(0.5926),
|
||||
point_biserial: Some(0.31),
|
||||
upper_group_rate: None,
|
||||
lower_group_rate: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
"o-third-line".to_string(),
|
||||
OptionStat {
|
||||
selection_rate: Some(0.1852),
|
||||
point_biserial: Some(-0.18),
|
||||
upper_group_rate: None,
|
||||
lower_group_rate: None,
|
||||
},
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
irt: None,
|
||||
flags: Vec::new(),
|
||||
}],
|
||||
};
|
||||
|
||||
let written = file.write_csv(&dir).unwrap();
|
||||
assert_eq!(written.len(), 2, "one table per shape");
|
||||
assert!(written[0].ends_with("e1-2026f-items.csv"));
|
||||
assert!(written[1].ends_with("e1-2026f-options.csv"));
|
||||
|
||||
// One line per question, loadable on its own: the administration's
|
||||
// metadata repeats on the row, as the response store already does.
|
||||
let items = std::fs::read_to_string(&written[0]).unwrap();
|
||||
assert!(
|
||||
items.starts_with("administration_id,assessment,term,date,forms"),
|
||||
"{items}"
|
||||
);
|
||||
assert!(
|
||||
items.contains("e1-2026f,e1,2026f,2026-09-15,A;B,27"),
|
||||
"{items}"
|
||||
);
|
||||
|
||||
let options = std::fs::read_to_string(&written[1]).unwrap();
|
||||
assert!(options.contains("o-fourth-line,true"), "{options}");
|
||||
assert!(options.contains("o-third-line,false"), "{options}");
|
||||
|
||||
let back = MeasurementFile::read_csv(&written[0]).unwrap();
|
||||
assert_eq!(back.administration.id, "e1-2026f");
|
||||
assert_eq!(back.administration.n_examinees, 27);
|
||||
assert_eq!(back.administration.forms, vec!["A", "B"]);
|
||||
assert_eq!(back.items.len(), 1);
|
||||
assert_eq!(back.items[0].p_value, Some(0.5926));
|
||||
assert_eq!(back.items[0].key, vec!["o-fourth-line"]);
|
||||
assert_eq!(back.items[0].option_stats.len(), 2);
|
||||
assert_eq!(back.items[0].discrimination_index, None);
|
||||
|
||||
assert_eq!(MeasurementFile::load_all(&dir).unwrap().len(), 1);
|
||||
|
||||
// It happened once, so the record is not replaced by accident.
|
||||
let err = file.write_csv(&dir).unwrap_err().to_string();
|
||||
assert!(err.contains("deliberate"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_student_column_is_refused_on_read() {
|
||||
let dir = tmp("identifiers");
|
||||
let path = dir.join("e1-2026f-items.csv");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"administration_id,assessment,item,number,n,student_key\n e1-2026f,e1,q-x,1,27,abc123\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// In YAML this fell out of the type, which rejects unknown keys. A CSV
|
||||
// reader tolerates extra columns, so the same assurance has to be an
|
||||
// explicit allowlist — and this is the test that it is one.
|
||||
let err = MeasurementFile::read_csv(&path).unwrap_err().to_string();
|
||||
assert!(err.contains("student_key"), "{err}");
|
||||
assert!(err.contains("cohort aggregates"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+417
-94
@@ -5,7 +5,7 @@
|
||||
//! 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
|
||||
//! by its id, 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
|
||||
@@ -20,19 +20,24 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::assessment::AssessmentFile;
|
||||
use crate::assessment::{AssessmentFile, Placement};
|
||||
use crate::bank::BankFile;
|
||||
use crate::course::CourseFile;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::item::Item;
|
||||
use crate::layout::Layout;
|
||||
use crate::taxonomy::{Level, Status};
|
||||
use crate::taxonomy::{Format, Level, Status, Tier};
|
||||
use crate::yaml;
|
||||
|
||||
/// One item plus everything needed to locate it again.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Entry {
|
||||
/// The globally unique id, `bank::item`.
|
||||
/// The item's id, which names it course-wide.
|
||||
///
|
||||
/// The bank is in [`Entry::bank`] and the file in [`Entry::path`], neither
|
||||
/// of which is part of the identity: this is the join key that response
|
||||
/// data carries, and a join key with a file name in it renames itself every
|
||||
/// time the files are reorganized.
|
||||
pub uid: String,
|
||||
/// The bank id.
|
||||
pub bank: String,
|
||||
@@ -70,6 +75,12 @@ pub struct Catalog {
|
||||
pub entries: Vec<Entry>,
|
||||
/// Bank metadata by bank id.
|
||||
pub banks: BTreeMap<String, crate::bank::BankMeta>,
|
||||
/// The statistics, loaded from `analysis/` rather than from the banks.
|
||||
///
|
||||
/// Each entry's [`crate::item::Item::calibration`] is filled from this at
|
||||
/// load, so everything downstream reads one item and does not have to know
|
||||
/// that the numbers and the wording come from different files.
|
||||
pub calibration: crate::calibration::CalibrationFile,
|
||||
/// Map from global id to index into `entries`.
|
||||
index: BTreeMap<String, usize>,
|
||||
}
|
||||
@@ -92,14 +103,17 @@ impl Catalog {
|
||||
/// 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 course = CourseFile::load_dir(root)?;
|
||||
let mut catalog = Catalog {
|
||||
course,
|
||||
layout,
|
||||
entries: Vec::new(),
|
||||
banks: BTreeMap::new(),
|
||||
index: BTreeMap::new(),
|
||||
calibration: crate::calibration::CalibrationFile::default(),
|
||||
};
|
||||
catalog.calibration =
|
||||
crate::calibration::CalibrationFile::load(&catalog.layout.calibration_file())?;
|
||||
|
||||
let mut problems = Vec::new();
|
||||
let mut files = yaml::list_yaml(&catalog.layout.banks())?;
|
||||
@@ -117,9 +131,13 @@ impl Catalog {
|
||||
}
|
||||
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}`"));
|
||||
let uid = item.id.clone();
|
||||
if let Some(first) = catalog.index.get(&uid) {
|
||||
problems.push(format!(
|
||||
"item id `{uid}` is used twice: in bank `{}` and in bank `{bank_id}`. An \
|
||||
id names one question course-wide, because response data joins on it.",
|
||||
catalog.entries[*first].bank
|
||||
));
|
||||
continue;
|
||||
}
|
||||
catalog.index.insert(uid.clone(), catalog.entries.len());
|
||||
@@ -136,20 +154,36 @@ impl Catalog {
|
||||
if !problems.is_empty() {
|
||||
return Err(Error::Invalid(problems));
|
||||
}
|
||||
// Statistics are attached here rather than parsed from the bank, which
|
||||
// is why an item can be read as one thing while its wording and its
|
||||
// evidence live in files with different review cycles.
|
||||
for entry in &mut catalog.entries {
|
||||
if let Some(calibration) = catalog.calibration.items.get(&entry.uid) {
|
||||
entry.item.calibration = Some(calibration.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
/// Looks up an item by global id.
|
||||
///
|
||||
/// A pre-2.0 `bank::item` id resolves to the item it used to name, so an
|
||||
/// assessment record or a parquet file written before the change still
|
||||
/// joins. See [`crate::item::canonical_id`].
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `uid` - the global id, `bank::item`.
|
||||
/// * `uid` - the item id, in either form.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The entry, or `None`.
|
||||
pub fn get(&self, uid: &str) -> Option<&Entry> {
|
||||
self.index.get(uid).map(|i| &self.entries[*i])
|
||||
self.index
|
||||
.get(uid)
|
||||
.or_else(|| self.index.get(crate::item::canonical_id(uid)))
|
||||
.map(|i| &self.entries[*i])
|
||||
}
|
||||
|
||||
/// Looks up an item by global id, erroring when absent.
|
||||
@@ -173,45 +207,37 @@ impl Catalog {
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves a possibly-unqualified id to a global id.
|
||||
/// Resolves an id written in either form to the canonical one.
|
||||
///
|
||||
/// 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.
|
||||
/// Since 2.0 an item id is already course-wide, so this is the identity for
|
||||
/// anything current. What it is still for is the old `bank::item` form,
|
||||
/// which appears in assessment records, seals, and response files written
|
||||
/// before the change, and which a person may well still type.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `id` - a global id, or a bare item id.
|
||||
/// * `id` - an item id, in either form.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The global id.
|
||||
/// The canonical id.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Unresolved`] when nothing matches, or [`Error::Usage`]
|
||||
/// when a bare id matches items in more than one bank.
|
||||
/// Returns [`Error::Unresolved`] when nothing matches.
|
||||
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(", ")
|
||||
))),
|
||||
let canonical = crate::item::canonical_id(id);
|
||||
if self.index.contains_key(canonical) {
|
||||
return Ok(canonical.to_string());
|
||||
}
|
||||
Err(Error::Unresolved {
|
||||
kind: "item",
|
||||
id: id.to_string(),
|
||||
context: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every item that may be placed on a graded assessment.
|
||||
@@ -232,12 +258,10 @@ impl Catalog {
|
||||
///
|
||||
/// 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();
|
||||
// Not prefixed here: `CourseFile::validate` attributes each message to
|
||||
// the fragment that defined the id, which for an unsplit course is
|
||||
// `course.yaml` and for a split one is the file worth opening.
|
||||
let mut issues: Vec<String> = self.course.validate();
|
||||
|
||||
for path in yaml::list_yaml(&self.layout.banks())? {
|
||||
let bank = BankFile::load_resolved(&path)?;
|
||||
@@ -255,6 +279,154 @@ impl Catalog {
|
||||
Ok(issues)
|
||||
}
|
||||
|
||||
/// Checks one placement's option set against the item's pool.
|
||||
///
|
||||
/// The checks that moved here from the bank when options became a pool. A
|
||||
/// bank holding two defensible keys and six distractors is sound; what has
|
||||
/// to hold for a *form* is that exactly one key reached the student, that
|
||||
/// none of the distractors was true, and that the count matches policy.
|
||||
/// None of that can be decided by looking at the item alone.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `p` - the placement.
|
||||
/// * `item` - the item it names.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// One message per problem.
|
||||
fn validate_placement(&self, p: &Placement, item: &Item) -> Vec<String> {
|
||||
let mut issues = Vec::new();
|
||||
if !item.format.has_options() {
|
||||
return issues;
|
||||
}
|
||||
let at = |number: u32| format!("question {number} ({})", p.item);
|
||||
|
||||
for id in p.key.iter().chain(p.distractors.iter()) {
|
||||
if item.option(id).is_none() {
|
||||
issues.push(format!(
|
||||
"{}: `{id}` is not an option of this item",
|
||||
at(p.number)
|
||||
));
|
||||
}
|
||||
}
|
||||
for id in &p.distractors {
|
||||
if p.key.iter().any(|k| k == id) {
|
||||
issues.push(format!(
|
||||
"{}: `{id}` is listed as both the key and a distractor",
|
||||
at(p.number)
|
||||
));
|
||||
}
|
||||
if item.option(id).is_some_and(|o| o.correct) {
|
||||
issues.push(format!(
|
||||
"{}: `{id}` is offered as a distractor but the bank keys it correct",
|
||||
at(p.number)
|
||||
));
|
||||
}
|
||||
}
|
||||
for id in &p.key {
|
||||
if item.option(id).is_some_and(|o| !o.correct) {
|
||||
issues.push(format!(
|
||||
"{}: `{id}` is keyed correct here but the bank does not key it. An option is \
|
||||
true or it is not; which true option a form uses is this record's choice, \
|
||||
but not whether it is true.",
|
||||
at(p.number)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let single = item.format == Format::SingleBestAnswer;
|
||||
if single && p.key.len() > 1 {
|
||||
issues.push(format!(
|
||||
"{}: single_best_answer administers exactly one key, this names {}",
|
||||
at(p.number),
|
||||
p.key.len()
|
||||
));
|
||||
}
|
||||
|
||||
// No distractor list means the whole pool, which is what a pre-2.0
|
||||
// record means and what an item whose pool is its form still means.
|
||||
// The record still has to say which key, when the pool offers a choice.
|
||||
if p.distractors.is_empty() {
|
||||
let (keys, _) = item.pool();
|
||||
if p.key.is_empty() && keys.len() > 1 && single {
|
||||
issues.push(format!(
|
||||
"{}: the item offers {} defensible keys, so the record has to say which one \
|
||||
this assessment used",
|
||||
at(p.number),
|
||||
keys.len()
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let shown = item.administered(&p.key, &p.distractors);
|
||||
let expected = self.course.policy.options_per_item;
|
||||
if shown.len() != expected {
|
||||
issues.push(format!(
|
||||
"{}: administers {} option(s), but course policy is {expected} per item",
|
||||
at(p.number),
|
||||
shown.len()
|
||||
));
|
||||
}
|
||||
if single && p.key.is_empty() {
|
||||
issues.push(format!(
|
||||
"{}: names its distractors but not its key, so what was marked correct is \
|
||||
left to whatever the bank says today",
|
||||
at(p.number)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(recorded) = &p.variant {
|
||||
if *recorded != item.variant_digest(&p.key, &p.distractors) {
|
||||
issues.push(format!(
|
||||
"{}: an administered option has been reworded since this assessment. \
|
||||
Statistics pooled under this variant describe the older wording.",
|
||||
at(p.number)
|
||||
));
|
||||
}
|
||||
}
|
||||
issues
|
||||
}
|
||||
|
||||
/// Checks every sealed administration's stems against the bank.
|
||||
///
|
||||
/// The seal is the authority on what was administered, so this is the
|
||||
/// comparison that matters: a record can be edited, but a seal is written
|
||||
/// before the exam is printed and digested against tampering. A stem that
|
||||
/// no longer matches the one a cohort answered means the id now names a
|
||||
/// different question, and every statistic pooled under it is describing
|
||||
/// two things at once.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `seals` - the sealed administrations to check.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// One message per stem that has moved out from under its seal.
|
||||
pub fn validate_seals(&self, seals: &[crate::seal::SealFile]) -> Vec<String> {
|
||||
let mut issues = Vec::new();
|
||||
for seal in seals {
|
||||
for item in &seal.items {
|
||||
let Some(digest) = &item.stem_digest else {
|
||||
continue;
|
||||
};
|
||||
let Some(entry) = self.get(&item.item) else {
|
||||
continue;
|
||||
};
|
||||
if *digest != entry.item.stem_digest() {
|
||||
issues.push(format!(
|
||||
"{}: question {} (`{}`) was administered with a different stem than the \
|
||||
bank now holds. Statistics from that administration describe the older \
|
||||
wording; give the new wording its own id.",
|
||||
seal.seal.assessment, item.number, item.item
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
issues
|
||||
}
|
||||
|
||||
/// Validates a record's internal invariants, then its references against this
|
||||
/// catalog: unknown items, keys that drifted, and fingerprints showing the
|
||||
/// item was reworded since it was administered.
|
||||
@@ -264,6 +436,22 @@ impl Catalog {
|
||||
match self.get(&p.item) {
|
||||
None => issues.push(format!("question {}: unknown item `{}`", p.number, p.item)),
|
||||
Some(entry) => {
|
||||
// The rule the stem digest exists to enforce. A changed
|
||||
// fingerprint is a note: the statistics describe an older
|
||||
// wording. A changed stem is an error: whatever was
|
||||
// administered is not the question the bank now holds, so
|
||||
// the id is being reused for two different questions.
|
||||
if let Some(digest) = &p.stem_digest {
|
||||
if *digest != entry.item.stem_digest() {
|
||||
issues.push(format!(
|
||||
"question {} ({}): the stem has been reworded since this \
|
||||
assessment. A reworded stem is a new question: give the new \
|
||||
wording a new id with `supersedes: {}`, and leave this one as \
|
||||
it was administered.",
|
||||
p.number, p.item, p.item
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(fp) = &p.fingerprint {
|
||||
if *fp != entry.item.fingerprint() {
|
||||
issues.push(format!(
|
||||
@@ -273,11 +461,7 @@ impl Catalog {
|
||||
));
|
||||
}
|
||||
}
|
||||
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.extend(self.validate_placement(p, &entry.item));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,19 +505,19 @@ impl Catalog {
|
||||
out
|
||||
}
|
||||
|
||||
/// Items that measure a given objective.
|
||||
/// Items tagged with a given learning target.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `objective` - the objective id.
|
||||
/// * `target` - the target id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Matching entries.
|
||||
pub fn by_objective(&self, objective: &str) -> Vec<&Entry> {
|
||||
pub fn by_target(&self, target: &str) -> Vec<&Entry> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|e| e.item.learning_objectives.iter().any(|o| o == objective))
|
||||
.filter(|e| e.item.learning_targets.iter().any(|t| t == target))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -384,55 +568,124 @@ impl Catalog {
|
||||
out
|
||||
}
|
||||
|
||||
/// Builds the coverage report.
|
||||
/// Items that measure a given objective, through any of its targets.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `objective` - the objective id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// One row per assessed objective plus a list of course-wide gaps.
|
||||
/// Entries tagged with the objective itself or with any of its targets,
|
||||
/// each appearing once even when it is tagged with two of them.
|
||||
pub fn by_objective(&self, objective: &str) -> Vec<&Entry> {
|
||||
self.entries
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
e.item
|
||||
.learning_targets
|
||||
.iter()
|
||||
.any(|t| t == objective || self.course.objective_for(t) == objective)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Builds the coverage report.
|
||||
///
|
||||
/// Rows cover both tiers, because the two answer different questions. An
|
||||
/// objective row answers "can I build an exam that reports on this
|
||||
/// objective", and aggregates every item under it. A target row answers
|
||||
/// "which specific things have I written items for", which is the
|
||||
/// authoring queue, and a target with no items is the most common and least
|
||||
/// visible hole in a bank: the objective looks well covered while a third of
|
||||
/// what it claims has never been asked.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// One row per assessed entry 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 {
|
||||
for id in self.course.registry_in_order() {
|
||||
if !self.course.is_objective(&id) && !self.course.is_target(&id) {
|
||||
continue;
|
||||
}
|
||||
let items = self.by_objective(&id);
|
||||
if !self.course.is_assessed(&id) {
|
||||
continue;
|
||||
}
|
||||
let targets = self.course.targets(&id);
|
||||
let tier = if self.course.is_objective(&id) {
|
||||
Tier::Objective
|
||||
} else {
|
||||
Tier::Target
|
||||
};
|
||||
// An objective's pool is everything under it; a target's is what is
|
||||
// tagged to it directly. An objective with no targets is its own
|
||||
// target, so the two agree there.
|
||||
let items = if targets.is_empty() {
|
||||
self.by_target(&id)
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
let targets_covered = targets
|
||||
.iter()
|
||||
.filter(|target| {
|
||||
self.by_target(target)
|
||||
.iter()
|
||||
.any(|e| e.item.is_assemblable())
|
||||
})
|
||||
.count();
|
||||
rows.push(CoverageRow {
|
||||
objective: id.clone(),
|
||||
text: obj.text.clone(),
|
||||
unit: obj.unit.clone(),
|
||||
id: id.clone(),
|
||||
text: self.course.text_for(&id),
|
||||
tier,
|
||||
objective: self
|
||||
.course
|
||||
.is_target(&id)
|
||||
.then(|| self.course.objective_for(&id).to_string()),
|
||||
unit: self.course.objective_unit(&id).map(str::to_string),
|
||||
targets: targets.len(),
|
||||
targets_covered,
|
||||
total: items.len(),
|
||||
assemblable: usable.len(),
|
||||
max_level: levels.iter().next_back().copied(),
|
||||
levels: levels.into_iter().collect(),
|
||||
ceiling: obj.level_ceiling,
|
||||
ceiling: self.course.effective_level_ceiling(&id),
|
||||
});
|
||||
}
|
||||
|
||||
let mut gaps = Vec::new();
|
||||
for row in &rows {
|
||||
// Gaps are raised against the tier that can be acted on. "No items
|
||||
// at all" is worth saying about a target, because writing one is the
|
||||
// fix. "Resting on a single item" is worth saying about an
|
||||
// objective, because a target resting on one item is the normal and
|
||||
// intended case, and flagging two hundred of them would bury the
|
||||
// rows that matter.
|
||||
if row.total == 0 {
|
||||
gaps.push(Gap::Uncovered(row.objective.clone()));
|
||||
gaps.push(Gap::Uncovered(row.id.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()));
|
||||
gaps.push(Gap::NoApprovedItems(row.id.clone()));
|
||||
} else if row.assemblable == 1 && row.tier == Tier::Objective {
|
||||
gaps.push(Gap::SingleItem(row.id.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 row.tier == Tier::Objective
|
||||
&& 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()));
|
||||
gaps.push(Gap::RecallOnly(row.id.clone()));
|
||||
}
|
||||
} else {
|
||||
gaps.push(Gap::RecallOnly(row.objective.clone()));
|
||||
gaps.push(Gap::RecallOnly(row.id.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -451,7 +704,7 @@ impl Catalog {
|
||||
|
||||
// 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() {
|
||||
if e.item.learning_targets.is_empty() && e.item.is_assemblable() {
|
||||
gaps.push(Gap::ItemWithoutObjective(e.uid.clone()));
|
||||
}
|
||||
}
|
||||
@@ -463,21 +716,35 @@ impl Catalog {
|
||||
/// The coverage report.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Coverage {
|
||||
/// One row per assessed objective.
|
||||
/// One row per assessed registry entry, objectives first with their targets
|
||||
/// following each.
|
||||
pub rows: Vec<CoverageRow>,
|
||||
/// Course-wide gaps worth acting on.
|
||||
pub gaps: Vec<Gap>,
|
||||
}
|
||||
|
||||
/// Coverage of one objective.
|
||||
/// Coverage of one registry entry, at either tier.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoverageRow {
|
||||
/// The objective id.
|
||||
pub objective: String,
|
||||
/// The objective text.
|
||||
/// The registry id.
|
||||
pub id: String,
|
||||
/// Its text.
|
||||
pub text: String,
|
||||
/// The unit it belongs to.
|
||||
/// Whether this is an objective, whose counts aggregate every item under it,
|
||||
/// or a target, whose counts are its own items.
|
||||
pub tier: Tier,
|
||||
/// The objective this row sits under, for a target.
|
||||
pub objective: Option<String>,
|
||||
/// The unit it belongs to, inherited from the objective when not declared.
|
||||
pub unit: Option<String>,
|
||||
/// Targets in the registry, for an objective row.
|
||||
pub targets: usize,
|
||||
/// Targets with at least one usable item.
|
||||
///
|
||||
/// The number to look at when an objective looks well covered: twenty items
|
||||
/// spread over four of its nine targets is a different bank from twenty
|
||||
/// items spread over all nine.
|
||||
pub targets_covered: usize,
|
||||
/// Items referencing it, at any status.
|
||||
pub total: usize,
|
||||
/// Items that could actually be used.
|
||||
@@ -486,16 +753,16 @@ pub struct CoverageRow {
|
||||
pub max_level: Option<Level>,
|
||||
/// Every level assessed.
|
||||
pub levels: Vec<Level>,
|
||||
/// The declared ceiling, when set.
|
||||
/// The ceiling in force, inherited from the objective when not declared.
|
||||
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.
|
||||
/// An assessed objective or target with no items at all.
|
||||
Uncovered(String),
|
||||
/// An objective whose items are all drafts or retired.
|
||||
/// An entry 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),
|
||||
@@ -606,7 +873,7 @@ items:
|
||||
level: 1
|
||||
cognitive_process: recall
|
||||
stem: What is x?
|
||||
learning_objectives: [lo-covered]
|
||||
learning_targets: [lo-covered]
|
||||
sources: [{ lecture: L01 }]
|
||||
design: { expected_difficulty: 0.8 }
|
||||
options:
|
||||
@@ -622,13 +889,32 @@ items:
|
||||
|
||||
let cat = Catalog::load(&dir).expect("catalog loads");
|
||||
assert_eq!(cat.entries.len(), 1);
|
||||
assert_eq!(cat.entries[0].uid, "b1::q-x-001");
|
||||
// The id names the item course-wide; the bank is where it is kept.
|
||||
assert_eq!(cat.entries[0].uid, "q-x-001");
|
||||
assert_eq!(cat.entries[0].bank, "b1");
|
||||
assert!(cat.get("q-x-001").is_some());
|
||||
assert_eq!(cat.resolve("q-x-001").unwrap(), "q-x-001");
|
||||
// A record or a parquet file written before 2.0 still joins.
|
||||
assert!(cat.get("b1::q-x-001").is_some());
|
||||
assert_eq!(cat.resolve("q-x-001").unwrap(), "b1::q-x-001");
|
||||
assert_eq!(cat.resolve("b1::q-x-001").unwrap(), "q-x-001");
|
||||
assert!(cat.get("b1::q-nonexistent").is_none());
|
||||
assert!(cat.validate().unwrap().is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_item_id_in_two_banks_is_fatal() {
|
||||
let dir = tmp("dupitem");
|
||||
write_course(&dir, "");
|
||||
write_bank(&dir, "b1.yaml", APPROVED);
|
||||
write_bank(&dir, "b2.yaml", &APPROVED.replace("id: b1", "id: b2"));
|
||||
|
||||
let message = Catalog::load(&dir).unwrap_err().to_string();
|
||||
assert!(message.contains("`q-x-001` is used twice"), "{message}");
|
||||
assert!(message.contains("course-wide"), "{message}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_bank_ids_are_fatal() {
|
||||
let dir = tmp("dupbank");
|
||||
@@ -640,21 +926,6 @@ items:
|
||||
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");
|
||||
@@ -672,6 +943,58 @@ items:
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_aggregates_targets_and_names_the_untested_ones() {
|
||||
let dir = tmp("tiered-coverage");
|
||||
std::fs::create_dir_all(dir.join("banks")).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("course.yaml"),
|
||||
r#"
|
||||
course: { code: TEST 101, title: Testing, term: Fall 2026 }
|
||||
lectures:
|
||||
L01: { title: One }
|
||||
learning_objectives:
|
||||
lo-binding: { text: Quantify binding., lectures: [L01], order: 1, level_ceiling: 3 }
|
||||
learning_targets:
|
||||
t-kd: { text: Write the expression., objective: lo-binding, order: 1 }
|
||||
t-plot: { text: Read a plot., objective: lo-binding, order: 2 }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Two items, both on the same target.
|
||||
let bank = APPROVED.replace("lo-covered", "t-kd").replace(
|
||||
" - { id: B, text: wrong }\n",
|
||||
" - { id: B, text: wrong }\n - id: q-x-002\n status: approved\n level: 3\n \
|
||||
cognitive_process: implement\n stem: And again?\n learning_targets: [t-kd]\n \
|
||||
sources: [{ lecture: L01 }]\n design: { expected_difficulty: 0.5 }\n options:\n \
|
||||
- { id: A, text: right, correct: true }\n - { id: B, text: wrong }\n",
|
||||
);
|
||||
write_bank(&dir, "b1.yaml", &bank);
|
||||
let cat = Catalog::load(&dir).unwrap();
|
||||
let cov = cat.coverage();
|
||||
|
||||
let objective = cov
|
||||
.rows
|
||||
.iter()
|
||||
.find(|r| r.id == "lo-binding")
|
||||
.expect("the objective row");
|
||||
assert_eq!(objective.tier, Tier::Objective);
|
||||
assert_eq!(
|
||||
objective.assemblable, 2,
|
||||
"the objective aggregates its targets"
|
||||
);
|
||||
assert_eq!(
|
||||
(objective.targets_covered, objective.targets),
|
||||
(1, 2),
|
||||
"two items, but only one of the two targets has been asked about"
|
||||
);
|
||||
// Resting on one item is the normal case for a target, so it is not a
|
||||
// gap; never having been asked about at all is.
|
||||
assert!(!cov.gaps.contains(&Gap::SingleItem("t-kd".into())));
|
||||
assert!(cov.gaps.contains(&Gap::Uncovered("t-plot".into())));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recall_only_respects_a_recall_ceiling() {
|
||||
let dir = tmp("ceiling");
|
||||
|
||||
+1507
-103
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,974 @@
|
||||
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! One course, several files.
|
||||
//!
|
||||
//! A course of forty lectures does not fit in a file anyone wants to scroll. So
|
||||
//! the registries [`CourseFile`] holds may be spread across a directory and
|
||||
//! merged on load:
|
||||
//!
|
||||
//! ```text
|
||||
//! course.yaml course, policy, units
|
||||
//! references.yaml references
|
||||
//! lectures/l-1-2.yaml one lecture, its readings, and what it teaches
|
||||
//! objectives/lo-x.yaml one objective and its targets
|
||||
//! ```
|
||||
//!
|
||||
//! The merge happens in memory on every command. Nothing is generated on disk
|
||||
//! and no command depends on a build step, because a generated file that other
|
||||
//! commands read is a file that goes stale. `coursebank course build` exists to
|
||||
//! show you the merged result, and nothing reads what it writes.
|
||||
//!
|
||||
//! # What this does not relax
|
||||
//!
|
||||
//! Splitting a file is only worth doing if it cannot introduce a second
|
||||
//! definition of the same thing. Two rules keep that true, and both are enforced
|
||||
//! here rather than left to convention:
|
||||
//!
|
||||
//! * **One definition site per id.** Two files defining `lo-read-file-formats`
|
||||
//! is an error naming both paths. The winner is not the last file loaded,
|
||||
//! because there is no winner.
|
||||
//! * **A section belongs to a kind of file.** A file under `lectures/` may not
|
||||
//! define `learning_objectives`. Otherwise the layout decays into forty files
|
||||
//! that each might hold anything, which is the same navigation problem in a
|
||||
//! worse shape.
|
||||
//!
|
||||
//! `course.yaml` is exempt from the second rule: a course that has not been
|
||||
//! split is a single fragment that happens to define everything, and it keeps
|
||||
//! loading unchanged.
|
||||
//!
|
||||
//! # Two derivations
|
||||
//!
|
||||
//! Splitting by lecture makes two fields tedious to maintain by hand, so they
|
||||
//! are derived instead:
|
||||
//!
|
||||
//! * A lecture's `teaches:` list adds that lecture to each named objective's
|
||||
//! `lectures`. You write what a lecture covers while planning the lecture,
|
||||
//! which is when you know.
|
||||
//! * A target with no `lectures` of its own inherits its objective's, the same
|
||||
//! way it already inherits `level_ceiling`.
|
||||
//!
|
||||
//! Both are unions and both are idempotent, so declaring a pair on both sides
|
||||
//! is redundant rather than contradictory.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
COURSE_FILE, Course, CourseFile, Lecture, Objective, Policy, Reference, SCHEMA_VERSION,
|
||||
SUPPORTED_MAJORS, Stimulus, Target, Unit,
|
||||
};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::layout::Layout;
|
||||
use crate::yaml;
|
||||
|
||||
/// The file holding the bibliography when it is kept out of `course.yaml`.
|
||||
pub const REFERENCES_FILE: &str = "references.yaml";
|
||||
|
||||
/// One registry section of a course.
|
||||
///
|
||||
/// Used to say which file a fact came from, and to keep a fragment from
|
||||
/// defining something that belongs somewhere else.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Section {
|
||||
/// Course identity.
|
||||
Course,
|
||||
/// Course-wide policy.
|
||||
Policy,
|
||||
/// Units.
|
||||
Units,
|
||||
/// Lectures.
|
||||
Lectures,
|
||||
/// Learning objectives.
|
||||
Objectives,
|
||||
/// Learning targets.
|
||||
Targets,
|
||||
/// Works the course cites.
|
||||
References,
|
||||
/// Shared stimuli.
|
||||
Stimuli,
|
||||
}
|
||||
|
||||
impl Section {
|
||||
/// Every section, in the order a merged course lists them.
|
||||
pub const ALL: [Section; 8] = [
|
||||
Section::Course,
|
||||
Section::Policy,
|
||||
Section::Units,
|
||||
Section::Lectures,
|
||||
Section::Objectives,
|
||||
Section::Targets,
|
||||
Section::References,
|
||||
Section::Stimuli,
|
||||
];
|
||||
|
||||
/// The YAML key this section is written under.
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Section::Course => "course",
|
||||
Section::Policy => "policy",
|
||||
Section::Units => "units",
|
||||
Section::Lectures => "lectures",
|
||||
Section::Objectives => "learning_objectives",
|
||||
Section::Targets => "learning_targets",
|
||||
Section::References => "references",
|
||||
Section::Stimuli => "stimuli",
|
||||
}
|
||||
}
|
||||
|
||||
/// What one of its entries is called in a message.
|
||||
pub fn noun(self) -> &'static str {
|
||||
match self {
|
||||
Section::Course => "course identity",
|
||||
Section::Policy => "policy",
|
||||
Section::Units => "unit",
|
||||
Section::Lectures => "lecture",
|
||||
Section::Objectives => "objective",
|
||||
Section::Targets => "target",
|
||||
Section::References => "reference",
|
||||
Section::Stimuli => "stimulus",
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a file defining this section is expected to live.
|
||||
pub fn home(self) -> &'static str {
|
||||
match self {
|
||||
Section::Course | Section::Policy | Section::Units => COURSE_FILE,
|
||||
Section::Lectures => "lectures/*.yaml",
|
||||
Section::Objectives | Section::Targets | Section::Stimuli => "objectives/*.yaml",
|
||||
Section::References => REFERENCES_FILE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Section {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.key())
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of file a fragment is, which fixes what it may define.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
/// `course.yaml`. May define anything, so an unsplit course still loads.
|
||||
Root,
|
||||
/// `references.yaml`.
|
||||
References,
|
||||
/// A file under `lectures/`.
|
||||
Lecture,
|
||||
/// A file under `objectives/`.
|
||||
Objective,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
/// Whether a file in this role may define a section.
|
||||
pub fn allows(self, section: Section) -> bool {
|
||||
match self {
|
||||
Role::Root => true,
|
||||
Role::References => section == Section::References,
|
||||
Role::Lecture => section == Section::Lectures,
|
||||
Role::Objective => matches!(
|
||||
section,
|
||||
Section::Objectives | Section::Targets | Section::Stimuli
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// A short name for a message.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Role::Root => "course",
|
||||
Role::References => "references",
|
||||
Role::Lecture => "lecture",
|
||||
Role::Objective => "objective",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One file's worth of course registries.
|
||||
///
|
||||
/// Every section is optional, which is what makes this both the fragment schema
|
||||
/// and — with every section filled in — the schema of an unsplit `course.yaml`.
|
||||
/// [`CourseFile`] is the resolved model the rest of the crate reads; this is
|
||||
/// only what one file on disk is allowed to say.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Fragment {
|
||||
/// Schema version this file targets.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "yaml::flexible_string_opt",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub schema_version: Option<String>,
|
||||
|
||||
/// Course identity. Exactly one fragment must carry it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub course: Option<Course>,
|
||||
|
||||
/// Course-wide policy.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub policy: Option<Policy>,
|
||||
|
||||
/// Units, in teaching order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub units: Vec<Unit>,
|
||||
|
||||
/// Lectures by id.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub lectures: BTreeMap<String, Lecture>,
|
||||
|
||||
/// Learning objectives by id.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub learning_objectives: BTreeMap<String, Objective>,
|
||||
|
||||
/// Learning targets by id.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub learning_targets: BTreeMap<String, Target>,
|
||||
|
||||
/// Works the course cites, by citation key.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub references: BTreeMap<String, Reference>,
|
||||
|
||||
/// Shared stimuli by id.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub stimuli: BTreeMap<String, Stimulus>,
|
||||
}
|
||||
|
||||
impl Fragment {
|
||||
/// Loads one fragment from disk.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - the file to read.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The parsed fragment.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Io`] if unreadable and [`Error::Yaml`] if it does not
|
||||
/// match the schema. Unknown keys are errors, so a misspelled section name
|
||||
/// is caught here rather than silently contributing nothing.
|
||||
pub fn load(path: &Path) -> Result<Fragment> {
|
||||
yaml::read(path)
|
||||
}
|
||||
|
||||
/// Which sections this fragment actually defines.
|
||||
pub fn sections(&self) -> Vec<Section> {
|
||||
let mut out = Vec::new();
|
||||
if self.course.is_some() {
|
||||
out.push(Section::Course);
|
||||
}
|
||||
if self.policy.is_some() {
|
||||
out.push(Section::Policy);
|
||||
}
|
||||
if !self.units.is_empty() {
|
||||
out.push(Section::Units);
|
||||
}
|
||||
if !self.lectures.is_empty() {
|
||||
out.push(Section::Lectures);
|
||||
}
|
||||
if !self.learning_objectives.is_empty() {
|
||||
out.push(Section::Objectives);
|
||||
}
|
||||
if !self.learning_targets.is_empty() {
|
||||
out.push(Section::Targets);
|
||||
}
|
||||
if !self.references.is_empty() {
|
||||
out.push(Section::References);
|
||||
}
|
||||
if !self.stimuli.is_empty() {
|
||||
out.push(Section::Stimuli);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The fragment files of a course directory, in load order, with their roles.
|
||||
///
|
||||
/// `course.yaml` is listed whether or not it exists, so a directory that is not
|
||||
/// a course fails with a message naming the file it wanted rather than an empty
|
||||
/// merge. Directory contents are sorted, which is what makes the merged course
|
||||
/// independent of filesystem order.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `layout` - the resolved course layout.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Paths paired with what each file is allowed to define.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Io`] when a fragment directory exists but cannot be read.
|
||||
pub fn files(layout: &Layout) -> Result<Vec<(PathBuf, Role)>> {
|
||||
let mut out = vec![(layout.course_file(), Role::Root)];
|
||||
|
||||
let references = layout.references_file();
|
||||
if references.is_file() {
|
||||
out.push((references, Role::References));
|
||||
}
|
||||
for path in yaml::list_yaml(&layout.lectures())? {
|
||||
out.push((path, Role::Lecture));
|
||||
}
|
||||
for path in yaml::list_yaml(&layout.objectives())? {
|
||||
out.push((path, Role::Objective));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Loads every fragment in a course directory and merges them into one course.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `root` - the course directory.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The merged course, with [`CourseFile::origins`] recording which file defined
|
||||
/// each id.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates load errors, and returns [`Error::Invalid`] with every merge
|
||||
/// problem at once: an id defined twice, a section in the wrong kind of file, a
|
||||
/// fragment written against another major schema version, or no `course:`
|
||||
/// section anywhere.
|
||||
///
|
||||
/// Cross-references are *not* checked here. A dangling objective id is a
|
||||
/// content problem, and content problems are [`CourseFile::validate`]'s, so that
|
||||
/// they are reported the same way whether or not the course is split.
|
||||
pub fn assemble(root: &Path) -> Result<CourseFile> {
|
||||
let layout = Layout::new(root);
|
||||
let mut merge = Merge::default();
|
||||
|
||||
for (path, role) in files(&layout)? {
|
||||
let fragment = Fragment::load(&path)?;
|
||||
let shown = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
|
||||
merge.take(&shown, role, fragment);
|
||||
}
|
||||
|
||||
merge.resolve();
|
||||
merge.finish(root)
|
||||
}
|
||||
|
||||
/// Accumulates fragments, remembering where each id came from.
|
||||
#[derive(Debug, Default)]
|
||||
struct Merge {
|
||||
schema_version: Option<String>,
|
||||
course: Option<Course>,
|
||||
policy: Option<Policy>,
|
||||
units: Vec<Unit>,
|
||||
lectures: BTreeMap<String, Lecture>,
|
||||
objectives: BTreeMap<String, Objective>,
|
||||
targets: BTreeMap<String, Target>,
|
||||
references: BTreeMap<String, Reference>,
|
||||
stimuli: BTreeMap<String, Stimulus>,
|
||||
origins: BTreeMap<(Section, String), PathBuf>,
|
||||
issues: Vec<String>,
|
||||
}
|
||||
|
||||
impl Merge {
|
||||
/// Folds one fragment in.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - the fragment's path relative to the course root, for messages.
|
||||
/// * `role` - what this file is allowed to define.
|
||||
/// * `fragment` - the parsed fragment.
|
||||
fn take(&mut self, path: &Path, role: Role, fragment: Fragment) {
|
||||
for section in fragment.sections() {
|
||||
if !role.allows(section) {
|
||||
self.issues.push(format!(
|
||||
"{}: a {} file may not define `{}`; that section belongs in {}",
|
||||
path.display(),
|
||||
role.label(),
|
||||
section.key(),
|
||||
section.home()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(declared) = &fragment.schema_version {
|
||||
if !SUPPORTED_MAJORS.contains(&major(declared)) {
|
||||
self.issues.push(format!(
|
||||
"{}: declares schema_version {declared}, which this build cannot read. It \
|
||||
writes {SCHEMA_VERSION} and reads {}.",
|
||||
path.display(),
|
||||
SUPPORTED_MAJORS
|
||||
.iter()
|
||||
.map(|m| format!("{m}.x"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" and ")
|
||||
));
|
||||
}
|
||||
if self.schema_version.is_none() {
|
||||
self.schema_version = Some(declared.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if role.allows(Section::Course) {
|
||||
if let Some(course) = fragment.course {
|
||||
if self.claim(Section::Course, path) {
|
||||
self.course = Some(course);
|
||||
}
|
||||
}
|
||||
}
|
||||
if role.allows(Section::Policy) {
|
||||
if let Some(policy) = fragment.policy {
|
||||
if self.claim(Section::Policy, path) {
|
||||
self.policy = Some(policy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if role.allows(Section::Units) {
|
||||
for unit in fragment.units {
|
||||
let key = (Section::Units, unit.id.clone());
|
||||
if let Some(first) = self.origins.get(&key) {
|
||||
let message = duplicate(Section::Units, &unit.id, first.as_path(), path);
|
||||
self.issues.push(message);
|
||||
continue;
|
||||
}
|
||||
self.origins.insert(key, path.to_path_buf());
|
||||
self.units.push(unit);
|
||||
}
|
||||
}
|
||||
|
||||
if role.allows(Section::Lectures) {
|
||||
absorb(
|
||||
&mut self.lectures,
|
||||
fragment.lectures,
|
||||
Section::Lectures,
|
||||
path,
|
||||
&mut self.origins,
|
||||
&mut self.issues,
|
||||
);
|
||||
}
|
||||
if role.allows(Section::Objectives) {
|
||||
absorb(
|
||||
&mut self.objectives,
|
||||
fragment.learning_objectives,
|
||||
Section::Objectives,
|
||||
path,
|
||||
&mut self.origins,
|
||||
&mut self.issues,
|
||||
);
|
||||
}
|
||||
if role.allows(Section::Targets) {
|
||||
absorb(
|
||||
&mut self.targets,
|
||||
fragment.learning_targets,
|
||||
Section::Targets,
|
||||
path,
|
||||
&mut self.origins,
|
||||
&mut self.issues,
|
||||
);
|
||||
}
|
||||
if role.allows(Section::References) {
|
||||
absorb(
|
||||
&mut self.references,
|
||||
fragment.references,
|
||||
Section::References,
|
||||
path,
|
||||
&mut self.origins,
|
||||
&mut self.issues,
|
||||
);
|
||||
}
|
||||
if role.allows(Section::Stimuli) {
|
||||
absorb(
|
||||
&mut self.stimuli,
|
||||
fragment.stimuli,
|
||||
Section::Stimuli,
|
||||
path,
|
||||
&mut self.origins,
|
||||
&mut self.issues,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a section that may only be declared once.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `section` - the section being claimed.
|
||||
/// * `path` - the file claiming it.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Whether the claim was the first, and so whether the caller should store
|
||||
/// what it parsed.
|
||||
fn claim(&mut self, section: Section, path: &Path) -> bool {
|
||||
let key = (section, String::new());
|
||||
if let Some(first) = self.origins.get(&key) {
|
||||
let message = format!(
|
||||
"`{}` is declared twice: {} and {}. It applies to the whole course, so it has \
|
||||
one definition site.",
|
||||
section.key(),
|
||||
first.display(),
|
||||
path.display()
|
||||
);
|
||||
self.issues.push(message);
|
||||
return false;
|
||||
}
|
||||
self.origins.insert(key, path.to_path_buf());
|
||||
true
|
||||
}
|
||||
|
||||
/// Fills in the two fields a split layout would otherwise duplicate.
|
||||
fn resolve(&mut self) {
|
||||
// A lecture says what it teaches; the objective's lecture list follows.
|
||||
for (lecture_id, lecture) in &self.lectures {
|
||||
for objective_id in &lecture.teaches {
|
||||
if let Some(objective) = self.objectives.get_mut(objective_id) {
|
||||
if !objective.lectures.iter().any(|l| l == lecture_id) {
|
||||
objective.lectures.push(lecture_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Teaching order, from the two sequences that already declare it: the
|
||||
// lectures in course order, and each lecture's `teaches` list. This is
|
||||
// what lets an objective stop carrying a hand-kept integer.
|
||||
let mut position = 0u32;
|
||||
let ordered: Vec<String> = self.lectures.keys().cloned().collect();
|
||||
for lecture_id in ordered {
|
||||
let teaches = self
|
||||
.lectures
|
||||
.get(&lecture_id)
|
||||
.map(|l| l.teaches.clone())
|
||||
.unwrap_or_default();
|
||||
for objective_id in teaches {
|
||||
position += 1;
|
||||
if let Some(objective) = self.objectives.get_mut(&objective_id) {
|
||||
if objective.order.is_none() {
|
||||
objective.order = Some(position);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A target with no lecture of its own is taught wherever its objective
|
||||
// is. Collected first: the read of `objectives` and the write to
|
||||
// `targets` cannot overlap in one pass.
|
||||
let inherited: Vec<(String, Vec<String>)> = self
|
||||
.targets
|
||||
.iter()
|
||||
.filter(|(_, target)| target.lectures.is_empty())
|
||||
.filter_map(|(id, target)| {
|
||||
self.objectives
|
||||
.get(&target.objective)
|
||||
.map(|objective| (id.clone(), objective.lectures.clone()))
|
||||
})
|
||||
.collect();
|
||||
for (id, lectures) in inherited {
|
||||
if let Some(target) = self.targets.get_mut(&id) {
|
||||
target.lectures = lectures;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the course, or reports every merge problem at once.
|
||||
fn finish(self, root: &Path) -> Result<CourseFile> {
|
||||
let mut issues = self.issues;
|
||||
let course = match self.course {
|
||||
Some(course) => course,
|
||||
None => {
|
||||
issues.push(format!(
|
||||
"no file in {} declares a `course:` section, so the course has no code, \
|
||||
title, or term",
|
||||
root.display()
|
||||
));
|
||||
return Err(Error::Invalid(issues));
|
||||
}
|
||||
};
|
||||
if !issues.is_empty() {
|
||||
return Err(Error::Invalid(issues));
|
||||
}
|
||||
|
||||
Ok(CourseFile {
|
||||
schema_version: self
|
||||
.schema_version
|
||||
.unwrap_or_else(|| SCHEMA_VERSION.to_string()),
|
||||
course,
|
||||
policy: self.policy.unwrap_or_default(),
|
||||
units: self.units,
|
||||
lectures: self.lectures,
|
||||
learning_objectives: self.objectives,
|
||||
learning_targets: self.targets,
|
||||
references: self.references,
|
||||
stimuli: self.stimuli,
|
||||
origins: self.origins,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves one section's entries across, refusing a second definition.
|
||||
fn absorb<T>(
|
||||
into: &mut BTreeMap<String, T>,
|
||||
from: BTreeMap<String, T>,
|
||||
section: Section,
|
||||
path: &Path,
|
||||
origins: &mut BTreeMap<(Section, String), PathBuf>,
|
||||
issues: &mut Vec<String>,
|
||||
) {
|
||||
for (id, value) in from {
|
||||
let key = (section, id.clone());
|
||||
if let Some(first) = origins.get(&key) {
|
||||
issues.push(duplicate(section, &id, first.as_path(), path));
|
||||
continue;
|
||||
}
|
||||
origins.insert(key, path.to_path_buf());
|
||||
into.insert(id, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// The message for an id defined in two files.
|
||||
fn duplicate(section: Section, id: &str, first: &Path, second: &Path) -> String {
|
||||
format!(
|
||||
"{} `{id}` is defined in two places: {} and {}. An id has one definition site; delete \
|
||||
one or rename it.",
|
||||
section.noun(),
|
||||
first.display(),
|
||||
second.display()
|
||||
)
|
||||
}
|
||||
|
||||
/// The part of a schema version before the first dot.
|
||||
fn major(version: &str) -> &str {
|
||||
version.split('.').next().unwrap_or(version)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmp(tag: &str) -> PathBuf {
|
||||
let p = std::env::temp_dir().join(format!("coursebank-frag-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&p);
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
fn write(root: &Path, relative: &str, body: &str) {
|
||||
let path = root.join(relative);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, body).unwrap();
|
||||
}
|
||||
|
||||
const ROOT: &str = r#"
|
||||
course:
|
||||
code: BIOSC 1540
|
||||
title: Computational Biology
|
||||
term: 2026f
|
||||
policy:
|
||||
points_per_item: 1.0
|
||||
units:
|
||||
- id: u1
|
||||
title: Search and Similarity
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn a_split_course_merges_into_one_model() {
|
||||
let root = tmp("merge");
|
||||
write(&root, "course.yaml", ROOT);
|
||||
write(
|
||||
&root,
|
||||
"references.yaml",
|
||||
"references:\n ismail2023:\n title: Bioinformatics\n",
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
"lectures/l-1-2.yaml",
|
||||
"lectures:\n L1.2:\n title: The Digital Genome\n unit: u1\n \
|
||||
teaches: [lo-read-file-formats]\n",
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
"objectives/lo-read-file-formats.yaml",
|
||||
"learning_objectives:\n lo-read-file-formats:\n text: Read the text formats.\n \
|
||||
unit: u1\nlearning_targets:\n t-fastq-structure:\n text: Identify the four \
|
||||
lines.\n objective: lo-read-file-formats\n",
|
||||
);
|
||||
|
||||
let course = assemble(&root).unwrap();
|
||||
assert_eq!(course.course.code, "BIOSC 1540");
|
||||
assert_eq!(course.units.len(), 1);
|
||||
assert_eq!(course.references.len(), 1);
|
||||
assert!(course.validate().is_empty(), "{:?}", course.validate());
|
||||
|
||||
// Derived: the lecture registered the objective, and the target
|
||||
// inherited the objective's lecture.
|
||||
assert_eq!(
|
||||
course.learning_objectives["lo-read-file-formats"].lectures,
|
||||
vec!["L1.2".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
course.learning_targets["t-fastq-structure"].lectures,
|
||||
vec!["L1.2".to_string()]
|
||||
);
|
||||
assert_eq!(course.lecture_targets("L1.2"), vec!["t-fastq-structure"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsplit_course_file_still_loads() {
|
||||
let root = tmp("monolith");
|
||||
write(
|
||||
&root,
|
||||
"course.yaml",
|
||||
&format!(
|
||||
"{ROOT}lectures:\n L1.2:\n title: The Digital Genome\nlearning_objectives:\n \
|
||||
lo-x:\n text: Do the thing.\n lectures: [L1.2]\nlearning_targets:\n \
|
||||
t-x:\n text: Do the smaller thing.\n objective: lo-x\nreferences:\n \
|
||||
ismail2023:\n title: Bioinformatics\n"
|
||||
),
|
||||
);
|
||||
|
||||
let course = assemble(&root).unwrap();
|
||||
assert!(course.validate().is_empty(), "{:?}", course.validate());
|
||||
assert_eq!(course.lecture_objectives("L1.2"), vec!["lo-x"]);
|
||||
assert_eq!(
|
||||
course.origin("lo-x").map(|(_, p)| p.to_path_buf()),
|
||||
Some(PathBuf::from(COURSE_FILE))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_id_defined_twice_names_both_files() {
|
||||
let root = tmp("dup");
|
||||
write(&root, "course.yaml", ROOT);
|
||||
let body = "learning_objectives:\n lo-x:\n text: Do the thing.\n";
|
||||
write(&root, "objectives/lo-x.yaml", body);
|
||||
write(&root, "objectives/lo-x-old.yaml", body);
|
||||
|
||||
let err = assemble(&root).unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("objectives/lo-x.yaml"), "{message}");
|
||||
assert!(message.contains("objectives/lo-x-old.yaml"), "{message}");
|
||||
assert!(message.contains("one definition site"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_section_in_the_wrong_kind_of_file_is_rejected() {
|
||||
let root = tmp("misplaced");
|
||||
write(&root, "course.yaml", ROOT);
|
||||
write(
|
||||
&root,
|
||||
"lectures/l-1-2.yaml",
|
||||
"lectures:\n L1.2:\n title: The Digital Genome\nlearning_objectives:\n lo-x:\n \
|
||||
text: Do the thing.\n",
|
||||
);
|
||||
|
||||
let message = assemble(&root).unwrap_err().to_string();
|
||||
assert!(
|
||||
message.contains("may not define `learning_objectives`"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(message.contains("objectives/*.yaml"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_course_with_no_identity_says_so() {
|
||||
let root = tmp("no-course");
|
||||
write(&root, "course.yaml", "units:\n - id: u1\n title: One\n");
|
||||
let message = assemble(&root).unwrap_err().to_string();
|
||||
assert!(message.contains("`course:` section"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fragment_from_another_major_version_is_refused() {
|
||||
let root = tmp("version");
|
||||
write(&root, "course.yaml", ROOT);
|
||||
write(
|
||||
&root,
|
||||
"objectives/lo-x.yaml",
|
||||
"schema_version: '9.0'\nlearning_objectives:\n lo-x:\n text: Do the thing.\n",
|
||||
);
|
||||
let message = assemble(&root).unwrap_err().to_string();
|
||||
assert!(message.contains("schema_version 9.0"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origins_point_at_the_fragment_that_defined_each_id() {
|
||||
let root = tmp("origins");
|
||||
write(&root, "course.yaml", ROOT);
|
||||
write(
|
||||
&root,
|
||||
"lectures/l-1-2.yaml",
|
||||
"lectures:\n L1.2:\n title: The Digital Genome\n",
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
"objectives/lo-x.yaml",
|
||||
"learning_objectives:\n lo-x:\n text: Do the thing.\n",
|
||||
);
|
||||
|
||||
let course = assemble(&root).unwrap();
|
||||
let (section, path) = course.origin("lo-x").unwrap();
|
||||
assert_eq!(section, Section::Objectives);
|
||||
assert_eq!(path, Path::new("objectives/lo-x.yaml"));
|
||||
let (section, path) = course.origin("L1.2").unwrap();
|
||||
assert_eq!(section, Section::Lectures);
|
||||
assert_eq!(path, Path::new("lectures/l-1-2.yaml"));
|
||||
assert!(course.origin("nothing-like-this").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teaching_order_is_derived_from_the_two_sequences_that_declare_it() {
|
||||
let root = tmp("order");
|
||||
write(&root, "course.yaml", ROOT);
|
||||
write(
|
||||
&root,
|
||||
"lectures/l-1-2.yaml",
|
||||
"lectures:\n L1.2:\n title: One\n teaches: [lo-second, lo-first]\n",
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
"lectures/l-1-3.yaml",
|
||||
"lectures:\n L1.3:\n title: Two\n teaches: [lo-third]\n",
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
"objectives/lo-first.yaml",
|
||||
r#"learning_objectives:
|
||||
lo-first:
|
||||
text: A.
|
||||
lo-second:
|
||||
text: B.
|
||||
lo-third:
|
||||
text: C.
|
||||
"#,
|
||||
);
|
||||
|
||||
let course = assemble(&root).unwrap();
|
||||
// Position in `teaches`, not id order: the lecture lists `lo-second`
|
||||
// first and that is what teaching it first means.
|
||||
assert_eq!(course.learning_objectives["lo-second"].order, Some(1));
|
||||
assert_eq!(course.learning_objectives["lo-first"].order, Some(2));
|
||||
assert_eq!(course.learning_objectives["lo-third"].order, Some(3));
|
||||
assert_eq!(
|
||||
course.lecture_objectives("L1.2"),
|
||||
vec!["lo-second", "lo-first"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targets_print_by_ceiling_rather_than_in_a_sequence() {
|
||||
let root = tmp("target-order");
|
||||
write(&root, "course.yaml", ROOT);
|
||||
write(
|
||||
&root,
|
||||
"lectures/l-1-2.yaml",
|
||||
"lectures:\n L1.2:\n title: One\n teaches: [lo-x]\n",
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
"objectives/lo-x.yaml",
|
||||
r#"learning_objectives:
|
||||
lo-x:
|
||||
text: A.
|
||||
level_ceiling: 3
|
||||
learning_targets:
|
||||
t-predict:
|
||||
text: Predict the effect.
|
||||
objective: lo-x
|
||||
level_ceiling: 3
|
||||
t-define:
|
||||
text: Define the term.
|
||||
objective: lo-x
|
||||
level_ceiling: 1
|
||||
t-explain:
|
||||
text: Explain the mechanism.
|
||||
objective: lo-x
|
||||
level_ceiling: 2
|
||||
"#,
|
||||
);
|
||||
|
||||
let course = assemble(&root).unwrap();
|
||||
assert!(course.validate().is_empty(), "{:?}", course.validate());
|
||||
|
||||
// Shallowest first, which is the order the study methods apply in:
|
||||
// recall, then explanation, then variations. Not id order, which would
|
||||
// put `t-define` after `t-predict` for no reason at all.
|
||||
assert_eq!(
|
||||
course.targets("lo-x"),
|
||||
vec!["t-define", "t-explain", "t-predict"]
|
||||
);
|
||||
|
||||
// A target with no ceiling of its own inherits the objective's, so it
|
||||
// sorts where that puts it.
|
||||
assert_eq!(course.learning_targets["t-define"].order, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teaching_the_same_objective_from_two_lectures_unions() {
|
||||
let root = tmp("union");
|
||||
write(&root, "course.yaml", ROOT);
|
||||
write(
|
||||
&root,
|
||||
"lectures/l-1-2.yaml",
|
||||
"lectures:\n L1.2:\n title: One\n teaches: [lo-x]\n",
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
"lectures/l-1-3.yaml",
|
||||
"lectures:\n L1.3:\n title: Two\n teaches: [lo-x]\n",
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
"objectives/lo-x.yaml",
|
||||
"learning_objectives:\n lo-x:\n text: Do the thing.\n",
|
||||
);
|
||||
|
||||
let course = assemble(&root).unwrap();
|
||||
assert_eq!(
|
||||
course.learning_objectives["lo-x"].lectures,
|
||||
vec!["L1.2".to_string(), "L1.3".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declaration_on_both_sides_is_not_duplicated() {
|
||||
let root = tmp("both-sides");
|
||||
write(&root, "course.yaml", ROOT);
|
||||
write(
|
||||
&root,
|
||||
"lectures/l-1-2.yaml",
|
||||
"lectures:\n L1.2:\n title: One\n teaches: [lo-x]\n",
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
"objectives/lo-x.yaml",
|
||||
"learning_objectives:\n lo-x:\n text: Do the thing.\n lectures: [L1.2]\n",
|
||||
);
|
||||
|
||||
let course = assemble(&root).unwrap();
|
||||
assert_eq!(
|
||||
course.learning_objectives["lo-x"].lectures,
|
||||
vec!["L1.2".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn teaching_an_unknown_objective_is_a_validation_problem_not_a_merge_one() {
|
||||
let root = tmp("unknown-teaches");
|
||||
write(&root, "course.yaml", ROOT);
|
||||
write(
|
||||
&root,
|
||||
"lectures/l-1-2.yaml",
|
||||
"lectures:\n L1.2:\n title: One\n teaches: [lo-nope]\n",
|
||||
);
|
||||
|
||||
let course = assemble(&root).unwrap();
|
||||
let issues = course.validate();
|
||||
assert!(issues.iter().any(|i| i.contains("lo-nope")), "{issues:?}");
|
||||
}
|
||||
}
|
||||
+496
-27
@@ -25,6 +25,7 @@ use serde::de::{self, MapAccess, Visitor};
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::course::Reference;
|
||||
use crate::date::Date;
|
||||
use crate::hash::fingerprint;
|
||||
use crate::taxonomy::{
|
||||
@@ -44,9 +45,28 @@ pub struct Item {
|
||||
|
||||
/// Revision counter, bumped whenever the content changes in a way that
|
||||
/// invalidates pooled statistics.
|
||||
#[serde(default = "one_u32")]
|
||||
/// Retained only so a pre-2.0 bank still loads. Ignored.
|
||||
///
|
||||
/// A version number on a question answered the wrong question. It recorded
|
||||
/// that *something* changed without constraining what, which meant an item
|
||||
/// at version 3 might have a reworded distractor — fair, the statistics
|
||||
/// still describe the same question — or a reworded stem, which makes it a
|
||||
/// different question wearing the same id. Since 2.0 the stem *is* the
|
||||
/// identity: reword it and you have a new item, with a new id and
|
||||
/// [`Item::supersedes`] pointing back. [`Item::stem_digest`] is what
|
||||
/// enforces that, against the seals of every administration.
|
||||
///
|
||||
/// `coursebank migrate stems` removes it.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub version: u32,
|
||||
|
||||
/// The item this one replaces, when it is a rewording of an earlier stem.
|
||||
///
|
||||
/// Lineage rather than versioning: both items stay in the bank, each with
|
||||
/// its own statistics, and a report can say which one a cohort answered.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub supersedes: Option<String>,
|
||||
|
||||
/// Workflow state; only [`Status::Approved`] items may be assembled.
|
||||
pub status: Status,
|
||||
|
||||
@@ -102,9 +122,19 @@ pub struct Item {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub solution: Option<Solution>,
|
||||
|
||||
/// Objectives this item measures, as ids into the course registry.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub learning_objectives: Vec<String>,
|
||||
/// The learning targets this item measures, as ids into the course
|
||||
/// registry.
|
||||
///
|
||||
/// Targets rather than objectives: an item measures one specific
|
||||
/// performance, and recording which one is what lets a report explain an
|
||||
/// objective's result instead of only stating it. The objective follows from
|
||||
/// the target, so it is never recorded twice.
|
||||
#[serde(
|
||||
default,
|
||||
alias = "learning_objectives",
|
||||
skip_serializing_if = "Vec::is_empty"
|
||||
)]
|
||||
pub learning_targets: Vec<String>,
|
||||
|
||||
/// Where the material was taught.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -114,7 +144,7 @@ pub struct Item {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub topics: Vec<String>,
|
||||
|
||||
/// Item ids or objective ids a student needs before this is fair.
|
||||
/// Item ids or registry ids a student needs before this is fair.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub prerequisites: Vec<String>,
|
||||
|
||||
@@ -127,7 +157,14 @@ pub struct Item {
|
||||
pub design: Option<Design>,
|
||||
|
||||
/// What the evidence says, accumulated across administrations.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
/// What the statistics say, filled in from `analysis/` at load.
|
||||
///
|
||||
/// Read from the store and never written back: `skip_serializing` means a
|
||||
/// bank file cannot acquire a `calibration:` block by being round-tripped
|
||||
/// through this type. A bank is a reviewed artifact whose diff should be a
|
||||
/// change of intent, and every grading run would otherwise produce a diff
|
||||
/// on it. See [`crate::calibration`].
|
||||
#[serde(default, skip_serializing)]
|
||||
pub calibration: Option<Calibration>,
|
||||
|
||||
/// The last review decision recorded for this item.
|
||||
@@ -135,7 +172,16 @@ pub struct Item {
|
||||
pub review: Option<Review>,
|
||||
|
||||
/// Append-only change log.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
/// Retained only so a pre-2.0 bank still loads. Ignored.
|
||||
///
|
||||
/// A hand-maintained change log inside a version-controlled file, every
|
||||
/// entry of which duplicated what `git log -p` already knew, with no
|
||||
/// guarantee of agreeing with it. What git cannot express is a claim about
|
||||
/// the item rather than a record of an edit, and that has its own fields:
|
||||
/// [`Item::retired`] and [`Item::supersedes`].
|
||||
///
|
||||
/// `coursebank migrate stems` removes it.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub history: Vec<HistoryEntry>,
|
||||
|
||||
/// The author of record.
|
||||
@@ -160,7 +206,17 @@ pub struct Item {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Choice {
|
||||
/// Option letter, `A` through `H`.
|
||||
/// The option's id, unique within its item: `o-fourth-line`.
|
||||
///
|
||||
/// A name rather than a position. Until 2.0 this was a letter, which put a
|
||||
/// position in a field that pooled statistics, student feedback, and
|
||||
/// `credit_overrides` all join on — so reordering a YAML block silently
|
||||
/// moved the misconception recorded against one option onto another. The
|
||||
/// letter a student sees is derived per form from the form's seed and lives
|
||||
/// in the seal; see [`crate::seal::printed_letter`].
|
||||
///
|
||||
/// A single letter `A` through `H` still loads, so a bank migrates when you
|
||||
/// run `coursebank migrate options` rather than when you upgrade.
|
||||
pub id: String,
|
||||
|
||||
/// The option text.
|
||||
@@ -208,6 +264,17 @@ pub struct Choice {
|
||||
/// 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>,
|
||||
|
||||
/// Why this option is no longer drawn, when it is not.
|
||||
///
|
||||
/// A retired option stays in the file forever. It has to: a seal and four
|
||||
/// terms of response rows refer to it by id, and deleting it would turn
|
||||
/// every one of those references into a dangling one. What retirement does
|
||||
/// is take it out of the pool an assessment draws from, with the reason
|
||||
/// attached — "selected by 1 of 96 across two administrations" is a finding
|
||||
/// about the option, and the place for it is next to the option.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retired: Option<Retirement>,
|
||||
}
|
||||
|
||||
impl Choice {
|
||||
@@ -309,6 +376,19 @@ impl Citation {
|
||||
(None, None) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The link for this location, resolved against the work it points into.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `reference` - the work, looked up from the citation key.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The most specific link available. See [`Reference::href`].
|
||||
pub fn href(&self, reference: &Reference) -> Option<String> {
|
||||
reference.href(self.url.as_deref(), self.path.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a citation as a mapping, or as a bare string when that is all it holds.
|
||||
@@ -543,6 +623,89 @@ pub struct Calibration {
|
||||
/// Machine-detected problems.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub flags: Vec<Flag>,
|
||||
|
||||
/// One record per option set ever administered.
|
||||
///
|
||||
/// What the flat fields above cannot express once options are a pool. A
|
||||
/// stem shown with distractors `{third, second, first}` is a measurably
|
||||
/// easier item than the same stem with `{third, plus-line, line-two}`, so a
|
||||
/// p-value pooled across both is the average of two different questions.
|
||||
/// Statistics are computed and compared per variant; the flat fields remain
|
||||
/// as the pre-2.0 summary, and for an item whose pool is its form the two
|
||||
/// agree.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub variants: Vec<VariantCalibration>,
|
||||
|
||||
/// One record per option, pooled across every set it appeared in.
|
||||
///
|
||||
/// The capability the pool is worth the trouble for. Selection rates are
|
||||
/// shares of a fixed set, so they are only comparable *within* a variant —
|
||||
/// which means this view supports exactly one kind of claim, and it is the
|
||||
/// useful one: this option draws nobody, anywhere. That is the evidence
|
||||
/// that retires a distractor, and one administration cannot supply it.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub options: std::collections::BTreeMap<String, OptionHistory>,
|
||||
}
|
||||
|
||||
/// Statistics for one option set, as administered.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct VariantCalibration {
|
||||
/// The digest this record describes. See [`Item::variant_digest`].
|
||||
pub variant: String,
|
||||
/// The option ids keyed correct.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub key: Vec<String>,
|
||||
/// The option ids offered alongside them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub distractors: Vec<String>,
|
||||
/// The administrations pooled into these numbers.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub administrations: Vec<String>,
|
||||
/// 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>,
|
||||
/// Per-option behaviour within this set, keyed by option id.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub option_stats: std::collections::BTreeMap<String, OptionStat>,
|
||||
/// Fitted item response theory parameters for this set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub irt: Option<IrtParams>,
|
||||
/// Machine-detected problems with this set.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub flags: Vec<Flag>,
|
||||
}
|
||||
|
||||
/// What one option has done across every set it has appeared in.
|
||||
///
|
||||
/// Deliberately coarse. Averaging selection rates across variants is not
|
||||
/// meaningful — each is a share of a different set — so `mean_selection_rate`
|
||||
/// is a summary for reading, not a statistic to act on. `never_chosen` is the
|
||||
/// one field that carries weight, and it needs several administrations to earn.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OptionHistory {
|
||||
/// How many distinct variants this option has appeared in.
|
||||
#[serde(default)]
|
||||
pub appearances: usize,
|
||||
/// Examinees who saw it, summed across those variants.
|
||||
#[serde(default)]
|
||||
pub n_examinees: usize,
|
||||
/// Mean of its within-variant selection rates. For reading only.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mean_selection_rate: Option<f64>,
|
||||
/// Whether it has never been chosen, anywhere.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub never_chosen: bool,
|
||||
}
|
||||
|
||||
/// How one option behaved.
|
||||
@@ -651,7 +814,7 @@ pub struct Retirement {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HistoryEntry {
|
||||
/// The version this change produced.
|
||||
/// The version this change produced. Ignored since 2.0.
|
||||
pub version: u32,
|
||||
/// When it was made.
|
||||
pub date: Date,
|
||||
@@ -662,6 +825,32 @@ pub struct HistoryEntry {
|
||||
pub change: String,
|
||||
}
|
||||
|
||||
/// The separator a pre-2.0 bank-qualified item id used: `b-1-2::q-fastq-line`.
|
||||
pub const LEGACY_QUALIFIER: &str = "::";
|
||||
|
||||
/// An item id with any pre-2.0 bank qualifier removed.
|
||||
///
|
||||
/// Until 2.0 an item was named `bank::item`, which made the file it happened to
|
||||
/// live in part of its identity — and therefore part of the join key on every
|
||||
/// row of response data ever collected. Moving a question between banks renamed
|
||||
/// it. Since 2.0 the id names the item course-wide and the bank is only where it
|
||||
/// is kept, so anything reading an old id strips the qualifier rather than
|
||||
/// failing to match.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `id` - an item id in either form.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The part after the qualifier, or the whole id when there is none.
|
||||
pub fn canonical_id(id: &str) -> &str {
|
||||
match id.split_once(LEGACY_QUALIFIER) {
|
||||
Some((_, rest)) => rest,
|
||||
None => id,
|
||||
}
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Builds a draft item with everything optional left empty.
|
||||
///
|
||||
@@ -671,7 +860,7 @@ impl Item {
|
||||
/// 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
|
||||
/// [`Item::is_assemblable`] — it still needs learning targets, sources, and
|
||||
/// a cognitive process before it can be drawn onto an assessment.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -703,7 +892,7 @@ impl Item {
|
||||
stem: stem.to_string(),
|
||||
options,
|
||||
solution: None,
|
||||
learning_objectives: Vec::new(),
|
||||
learning_targets: Vec::new(),
|
||||
sources: Vec::new(),
|
||||
topics: Vec::new(),
|
||||
prerequisites: Vec::new(),
|
||||
@@ -715,6 +904,7 @@ impl Item {
|
||||
author: None,
|
||||
notes_private: None,
|
||||
retired: None,
|
||||
supersedes: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,19 +938,38 @@ impl Item {
|
||||
out
|
||||
}
|
||||
|
||||
/// Looks up an option by letter.
|
||||
/// Looks up an option by id.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `letter` - the option id, case insensitive.
|
||||
/// * `id` - the option id. A pre-2.0 letter matches case-insensitively,
|
||||
/// which a slug never needs but a hand-typed `d` does.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The option, or `None`.
|
||||
pub fn option(&self, letter: &str) -> Option<&Choice> {
|
||||
pub fn option(&self, id: &str) -> Option<&Choice> {
|
||||
self.options
|
||||
.iter()
|
||||
.find(|o| o.id.eq_ignore_ascii_case(letter))
|
||||
.find(|o| o.id == id)
|
||||
.or_else(|| self.options.iter().find(|o| o.id.eq_ignore_ascii_case(id)))
|
||||
}
|
||||
|
||||
/// Whether an option id is a pre-2.0 letter rather than a name.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `id` - the option id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` for `A` through `H`.
|
||||
pub fn is_legacy_option_id(id: &str) -> bool {
|
||||
id.len() == 1
|
||||
&& id
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_uppercase() && c <= 'H')
|
||||
}
|
||||
|
||||
/// Whether the item keys more than one option.
|
||||
@@ -798,7 +1007,7 @@ impl Item {
|
||||
|
||||
/// A content fingerprint over everything that affects what a student sees.
|
||||
///
|
||||
/// Metadata deliberately does not contribute: retagging an objective must not
|
||||
/// Metadata deliberately does not contribute: retagging a target must not
|
||||
/// invalidate pooled statistics, but rewording an option must.
|
||||
///
|
||||
/// # Returns
|
||||
@@ -824,6 +1033,159 @@ impl Item {
|
||||
fingerprint(parts.iter().map(|s| s.as_str()))
|
||||
}
|
||||
|
||||
/// The options an assessment administers, in the order the bank declares
|
||||
/// them.
|
||||
///
|
||||
/// Since 2.0 `options` is a *pool*: it may hold several defensible keys and
|
||||
/// more distractors than any one form shows, and which of them a student
|
||||
/// saw is a property of the placement rather than of the item. Everything
|
||||
/// that renders, seals, decodes, or scores an administration has to work
|
||||
/// from this rather than from `options`, or the paper and the key disagree.
|
||||
///
|
||||
/// Bank order, not administered order: the per-form permutation is
|
||||
/// [`crate::select::option_order`]'s business, and keeping the two separate
|
||||
/// is what lets one item appear on three forms with one set of statistics.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - the option ids keyed correct for this administration.
|
||||
/// * `distractors` - the option ids offered alongside them.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The named options, or the whole live pool when `distractors` is empty.
|
||||
///
|
||||
/// `distractors` is what says the set was chosen, not `key`. A pre-2.0
|
||||
/// record names its key and nothing else — `key: [D]` with no distractor
|
||||
/// list — and it means "all of them, and D is the right one". Reading that
|
||||
/// as "administer D alone" would print a one-option paper for every
|
||||
/// assessment ever recorded.
|
||||
pub fn administered(&self, key: &[String], distractors: &[String]) -> Vec<&Choice> {
|
||||
if distractors.is_empty() {
|
||||
return self
|
||||
.options
|
||||
.iter()
|
||||
.filter(|o| o.retired.is_none())
|
||||
.collect();
|
||||
}
|
||||
self.options
|
||||
.iter()
|
||||
.filter(|o| key.contains(&o.id) || distractors.contains(&o.id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The options that may still be drawn.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Every option not retired, split into candidate keys and distractors.
|
||||
pub fn pool(&self) -> (Vec<&Choice>, Vec<&Choice>) {
|
||||
let live = || self.options.iter().filter(|o| o.retired.is_none());
|
||||
(
|
||||
live().filter(|o| o.correct).collect(),
|
||||
live().filter(|o| !o.correct).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// A digest of the item as one administration showed it.
|
||||
///
|
||||
/// The pooling key for statistics, and the reason
|
||||
/// [`Item::fingerprint`] cannot be. A stem with distractors
|
||||
/// `{third, second, first}` is a measurably easier item than the same stem
|
||||
/// with `{third, plus-line, line-two}`, so pooling a p-value across both is
|
||||
/// averaging two different questions. Covers the stem, the administered
|
||||
/// options, and which of them was keyed — the last because the same option
|
||||
/// set with a different key is again a different item.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - the option ids keyed correct for this administration.
|
||||
/// * `distractors` - the option ids offered alongside them.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The digest as hex.
|
||||
pub fn variant_digest(&self, key: &[String], distractors: &[String]) -> String {
|
||||
let mut parts = vec![self.stem_digest()];
|
||||
let mut shown: Vec<&Choice> = self.administered(key, distractors);
|
||||
shown.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
for option in shown {
|
||||
let keyed = if key.is_empty() {
|
||||
option.correct
|
||||
} else {
|
||||
key.contains(&option.id)
|
||||
};
|
||||
parts.push(format!(
|
||||
"{}|{}|{}",
|
||||
option.id,
|
||||
if keyed { "1" } else { "0" },
|
||||
option.text.trim()
|
||||
));
|
||||
}
|
||||
fingerprint(parts.iter().map(|s| s.as_str()))
|
||||
}
|
||||
|
||||
/// A digest of what the item asks, without its options.
|
||||
///
|
||||
/// The identity check. [`Item::fingerprint`] covers the options too, which
|
||||
/// is right for calibration — reword a distractor and the pooled selection
|
||||
/// rates no longer describe what students saw — but wrong for identity,
|
||||
/// because a question whose distractors changed is still the same question.
|
||||
/// This covers the stem and the stimulus, and nothing else.
|
||||
///
|
||||
/// Compared against the digest each seal recorded, which is what makes
|
||||
/// "a reworded stem is a new stem" a rule the tool enforces rather than a
|
||||
/// convention that decays.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The digest as hex.
|
||||
pub fn stem_digest(&self) -> String {
|
||||
let mut parts: Vec<String> = vec![self.stem.trim().to_string()];
|
||||
if let Some(s) = &self.stimulus {
|
||||
parts.push(format!("stimulus:{s}"));
|
||||
}
|
||||
fingerprint(parts.iter().map(|s| s.as_str()))
|
||||
}
|
||||
|
||||
/// The calibration recorded for one option set.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `variant` - the digest from [`Item::variant_digest`].
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The record, or `None` when this set has not been calibrated.
|
||||
pub fn calibration_for(&self, variant: &str) -> Option<&VariantCalibration> {
|
||||
self.calibration
|
||||
.as_ref()?
|
||||
.variants
|
||||
.iter()
|
||||
.find(|v| v.variant == variant)
|
||||
}
|
||||
|
||||
/// Whether a variant's recorded statistics still describe it.
|
||||
///
|
||||
/// Staleness gets *narrower* with a pool rather than wider: rewording one
|
||||
/// distractor used to invalidate the item's whole calibration, and now it
|
||||
/// invalidates only the sets that distractor appeared in.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `variant` - the digest to check.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `false` only when a record exists for that digest and the digest no
|
||||
/// longer matches what the option ids now say.
|
||||
pub fn variant_is_current(&self, variant: &str) -> bool {
|
||||
match self.calibration_for(variant) {
|
||||
Some(record) => variant == self.variant_digest(&record.key, &record.distractors),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the recorded calibration matches the current content.
|
||||
///
|
||||
/// # Returns
|
||||
@@ -879,16 +1241,20 @@ impl Item {
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends a change-log entry and bumps the version.
|
||||
/// Appends a change-log entry.
|
||||
///
|
||||
/// Kept for the pre-2.0 banks that still carry a `history:` block, so
|
||||
/// reading one and writing it back does not silently drop entries. New
|
||||
/// entries belong in a commit message.
|
||||
///
|
||||
/// # 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;
|
||||
let version = self.history.iter().map(|h| h.version).max().unwrap_or(0) + 1;
|
||||
self.history.push(HistoryEntry {
|
||||
version: self.version,
|
||||
version,
|
||||
date: Date::today(),
|
||||
author: author.map(|a| a.to_string()),
|
||||
change: change.to_string(),
|
||||
@@ -896,9 +1262,6 @@ impl Item {
|
||||
}
|
||||
}
|
||||
|
||||
fn one_u32() -> u32 {
|
||||
1
|
||||
}
|
||||
fn default_format() -> Format {
|
||||
Format::SingleBestAnswer
|
||||
}
|
||||
@@ -928,7 +1291,6 @@ options:
|
||||
#[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"]);
|
||||
@@ -964,7 +1326,7 @@ options:
|
||||
let base = item(MINIMAL);
|
||||
let mut retagged = base.clone();
|
||||
retagged.topics = vec!["kinetics".into()];
|
||||
retagged.learning_objectives = vec!["lo-a".into()];
|
||||
retagged.learning_targets = vec!["lo-a".into()];
|
||||
retagged.author = Some("someone".into());
|
||||
assert_eq!(
|
||||
base.fingerprint(),
|
||||
@@ -1000,6 +1362,101 @@ options:
|
||||
assert_eq!(a.fingerprint(), b.fingerprint());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pool_administers_a_subset_and_defaults_to_everything() {
|
||||
let mut it = item(MINIMAL);
|
||||
let all: Vec<String> = it.options.iter().map(|o| o.id.clone()).collect();
|
||||
|
||||
// Unstated means the whole pool, which is what a pre-2.0 record meant.
|
||||
assert_eq!(it.administered(&[], &[]).len(), all.len());
|
||||
|
||||
// A retired option leaves the pool but not the file.
|
||||
it.options[1].retired = Some(Retirement {
|
||||
on: Date::new(2026, 9, 20).unwrap(),
|
||||
reason: "chosen by 1 of 96 across two administrations".into(),
|
||||
replaced_by: None,
|
||||
});
|
||||
let shown = it.administered(&[], &[]);
|
||||
assert_eq!(shown.len(), all.len() - 1);
|
||||
assert!(!shown.iter().any(|o| o.id == all[1]));
|
||||
// Still resolvable: a seal and four terms of rows refer to it.
|
||||
assert!(it.option(&all[1]).is_some());
|
||||
|
||||
// A key with no distractor list is a pre-2.0 record, and it means all
|
||||
// of them. Reading it as "administer the key alone" would print a
|
||||
// one-option paper for every assessment already recorded.
|
||||
assert_eq!(it.administered(&[all[2].clone()], &[]).len(), all.len() - 1);
|
||||
|
||||
// Named explicitly, bank order is kept whatever order the lists are in.
|
||||
let shown = it.administered(&[all[2].clone()], &[all[0].clone()]);
|
||||
assert_eq!(
|
||||
shown.iter().map(|o| o.id.clone()).collect::<Vec<_>>(),
|
||||
vec![all[0].clone(), all[2].clone()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_variant_digest_tracks_the_option_set_and_the_stem_does_not() {
|
||||
let it = item(MINIMAL);
|
||||
let ids: Vec<String> = it.options.iter().map(|o| o.id.clone()).collect();
|
||||
|
||||
let one = it.variant_digest(&[ids[0].clone()], &[ids[1].clone()]);
|
||||
let two = it.variant_digest(&[ids[0].clone()], &[ids[2].clone()]);
|
||||
// A different distractor is a different item: same stem, different
|
||||
// difficulty, so pooling a p-value across both would average two
|
||||
// questions.
|
||||
assert_ne!(one, two, "a swapped distractor is a new variant");
|
||||
// The stem is unmoved by any of it.
|
||||
assert_eq!(it.stem_digest(), item(MINIMAL).stem_digest());
|
||||
|
||||
// Order of the lists is not part of the identity.
|
||||
assert_eq!(
|
||||
it.variant_digest(&[ids[0].clone()], &[ids[2].clone(), ids[1].clone()]),
|
||||
it.variant_digest(&[ids[0].clone()], &[ids[1].clone(), ids[2].clone()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_variant_goes_stale_alone_rather_than_taking_the_item_with_it() {
|
||||
let mut it = item(MINIMAL);
|
||||
let ids: Vec<String> = it.options.iter().map(|o| o.id.clone()).collect();
|
||||
let one = it.variant_digest(&[ids[0].clone()], &[ids[1].clone()]);
|
||||
let two = it.variant_digest(&[ids[0].clone()], &[ids[2].clone()]);
|
||||
|
||||
it.calibration = Some(Calibration {
|
||||
variants: vec![
|
||||
VariantCalibration {
|
||||
variant: one.clone(),
|
||||
key: vec![ids[0].clone()],
|
||||
distractors: vec![ids[1].clone()],
|
||||
n_examinees: Some(96),
|
||||
p_value: Some(0.84),
|
||||
..VariantCalibration::default()
|
||||
},
|
||||
VariantCalibration {
|
||||
variant: two.clone(),
|
||||
key: vec![ids[0].clone()],
|
||||
distractors: vec![ids[2].clone()],
|
||||
n_examinees: Some(32),
|
||||
..VariantCalibration::default()
|
||||
},
|
||||
],
|
||||
..Calibration::default()
|
||||
});
|
||||
|
||||
assert_eq!(it.calibration_for(&one).unwrap().n_examinees, Some(96));
|
||||
assert!(it.calibration_for("nothing-like-this").is_none());
|
||||
assert!(it.variant_is_current(&one));
|
||||
assert!(it.variant_is_current(&two));
|
||||
|
||||
// Rewording the option that only the second set used leaves the first
|
||||
// set's numbers standing. Before the pool, one distractor edit
|
||||
// invalidated every statistic the item had.
|
||||
it.options[2].text = "a different distractor".into();
|
||||
assert!(it.variant_is_current(&one), "the first set never showed it");
|
||||
assert!(!it.variant_is_current(&two));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_calibration_is_detectable() {
|
||||
let mut it = item(MINIMAL);
|
||||
@@ -1043,12 +1500,24 @@ options:
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_change_bumps_version_and_logs() {
|
||||
fn the_stem_is_the_identity_rather_than_a_version_number() {
|
||||
let mut it = item(MINIMAL);
|
||||
let before = it.stem_digest();
|
||||
|
||||
// A change log entry numbers itself and leaves the item alone: since
|
||||
// 2.0 nothing reads `version`, and rewording a stem is not a version
|
||||
// bump but a new item.
|
||||
it.record_change("clarified the stem", Some("Alex"));
|
||||
assert_eq!(it.version, 2);
|
||||
assert_eq!(it.version, 0);
|
||||
assert_eq!(it.history.len(), 1);
|
||||
assert_eq!(it.history[0].version, 2);
|
||||
assert_eq!(it.history[0].version, 1);
|
||||
assert_eq!(it.stem_digest(), before);
|
||||
|
||||
// The options are the fingerprint's business, not the stem's.
|
||||
it.options[1].text = "a different distractor".into();
|
||||
assert_eq!(it.stem_digest(), before);
|
||||
it.stem = "What is y?".into();
|
||||
assert_ne!(it.stem_digest(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! The on-disk layout of a course directory.
|
||||
//!
|
||||
//! Two of these directories hold fragments of the course file rather than files
|
||||
//! of their own kind: `lectures/` and `objectives/` are merged into one
|
||||
//! [`crate::course::CourseFile`] on load, along with `references.yaml`. See
|
||||
//! [`crate::course::fragment`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -35,11 +40,48 @@ impl Layout {
|
||||
self.root.join(COURSE_FILE)
|
||||
}
|
||||
|
||||
/// Path to `references.yaml`, the bibliography when it is kept out of
|
||||
/// `course.yaml`.
|
||||
///
|
||||
/// Optional: absent means the course keeps its `references:` section in the
|
||||
/// course file, which is how an unsplit course is arranged.
|
||||
pub fn references_file(&self) -> PathBuf {
|
||||
self.root.join(crate::course::fragment::REFERENCES_FILE)
|
||||
}
|
||||
|
||||
/// Directory holding one file per lecture.
|
||||
pub fn lectures(&self) -> PathBuf {
|
||||
self.root.join("lectures")
|
||||
}
|
||||
|
||||
/// Directory holding one file per learning objective.
|
||||
pub fn objectives(&self) -> PathBuf {
|
||||
self.root.join("objectives")
|
||||
}
|
||||
|
||||
/// Directory holding item bank YAML files.
|
||||
pub fn banks(&self) -> PathBuf {
|
||||
self.root.join("banks")
|
||||
}
|
||||
|
||||
/// Directory holding the statistics, which are kept out of the banks.
|
||||
///
|
||||
/// Committed, unlike [`Layout::data`]: everything under here is a cohort
|
||||
/// aggregate with no student in it. See [`crate::calibration`].
|
||||
pub fn analysis(&self) -> PathBuf {
|
||||
self.root.join("analysis")
|
||||
}
|
||||
|
||||
/// The pooled per-item calibration store.
|
||||
pub fn calibration_file(&self) -> PathBuf {
|
||||
self.analysis().join(crate::calibration::CALIBRATION_FILE)
|
||||
}
|
||||
|
||||
/// Directory holding one immutable record per administration.
|
||||
pub fn measurements(&self) -> PathBuf {
|
||||
self.analysis().join("administrations")
|
||||
}
|
||||
|
||||
/// Directory holding assessment records.
|
||||
pub fn assessments(&self) -> PathBuf {
|
||||
self.root.join("assessments")
|
||||
@@ -93,6 +135,10 @@ impl Layout {
|
||||
pub fn create_all(&self) -> Result<()> {
|
||||
for dir in [
|
||||
self.root.clone(),
|
||||
self.analysis(),
|
||||
self.measurements(),
|
||||
self.lectures(),
|
||||
self.objectives(),
|
||||
self.banks(),
|
||||
self.assessments(),
|
||||
self.data(),
|
||||
|
||||
+139
-14
@@ -124,6 +124,26 @@ pub struct SealMeta {
|
||||
/// from the file's own contents; [`SealFile::verify`] rebuilds a seal from the
|
||||
/// live course and compares this against it.
|
||||
pub digest: String,
|
||||
|
||||
/// The digests this file had before it was rewritten, oldest first.
|
||||
///
|
||||
/// A seal is meant to be tamper-evident, which puts a migration that
|
||||
/// renames an id inside it in an awkward position: the id is part of
|
||||
/// [`SealFile::digest_input`], so rewriting the file invalidates the digest
|
||||
/// that vouched for it, and recomputing it quietly would produce a record
|
||||
/// that looks untouched and is not.
|
||||
///
|
||||
/// So a migration does both — recomputes the digest and leaves the old one
|
||||
/// here. What the seal then says is the honest thing: these are the
|
||||
/// contents, this is what they hash to, and here is what the file hashed to
|
||||
/// before each rewrite. Anyone holding an earlier copy, a backup or a git
|
||||
/// revision, can check it against the right entry.
|
||||
///
|
||||
/// Deliberately outside [`SealFile::digest_input`]: a field that recorded
|
||||
/// past digests and was itself covered by the current one could not be
|
||||
/// appended to without invalidating what it describes.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub superseded_digests: Vec<String>,
|
||||
}
|
||||
|
||||
/// What wrote a seal.
|
||||
@@ -145,8 +165,16 @@ pub struct SealedItem {
|
||||
/// The item's global id.
|
||||
pub item: String,
|
||||
/// The item version as administered.
|
||||
/// Retained only so a pre-2.0 seal still loads. Ignored when reasoning
|
||||
/// about the item, but still written: [`SealFile::digest_input`] covers it,
|
||||
/// so dropping it on a round trip would invalidate the digest of every
|
||||
/// administration sealed before 2.0.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<u32>,
|
||||
|
||||
/// The stem's digest as administered.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stem_digest: Option<String>,
|
||||
/// The item's content fingerprint, the same one
|
||||
/// [`crate::item::Item::fingerprint`] computes, so a seal and a bank can be
|
||||
/// compared without re-hashing either by hand.
|
||||
@@ -161,7 +189,7 @@ pub struct SealedItem {
|
||||
pub level: Option<Level>,
|
||||
/// Objectives as administered.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub learning_objectives: Vec<String>,
|
||||
pub learning_targets: Vec<String>,
|
||||
/// The keyed letters in the bank's own lettering, before any shuffle.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub key: Vec<String>,
|
||||
@@ -450,6 +478,7 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Resu
|
||||
content: opts.content,
|
||||
dropped,
|
||||
digest: String::new(),
|
||||
superseded_digests: Vec::new(),
|
||||
},
|
||||
items,
|
||||
forms: sealed_forms,
|
||||
@@ -484,17 +513,18 @@ fn sealed_item(
|
||||
SealedItem {
|
||||
number: placement.number,
|
||||
item: placement.item.clone(),
|
||||
version: placement.version.or(Some(item.version)),
|
||||
version: None,
|
||||
stem_digest: Some(item.stem_digest()),
|
||||
fingerprint: item.fingerprint(),
|
||||
points: placement
|
||||
.points
|
||||
.unwrap_or_else(|| item.points(default_points)),
|
||||
bonus: placement.bonus,
|
||||
level: placement.level.or(Some(item.level)),
|
||||
learning_objectives: if placement.learning_objectives.is_empty() {
|
||||
item.learning_objectives.clone()
|
||||
learning_targets: if placement.learning_targets.is_empty() {
|
||||
item.learning_targets.clone()
|
||||
} else {
|
||||
placement.learning_objectives.clone()
|
||||
placement.learning_targets.clone()
|
||||
},
|
||||
key: if placement.key.is_empty() {
|
||||
item.key_letters()
|
||||
@@ -518,18 +548,21 @@ fn sealed_item(
|
||||
fn sealed_form(catalog: &Catalog, record: &AssessmentFile, form: &Form) -> Result<SealedForm> {
|
||||
let mut questions = Vec::new();
|
||||
|
||||
// Dropped placements are not printed, so they take no printed position. A
|
||||
// drop recorded before sealing therefore shifts every later position, exactly
|
||||
// as it shifts them on the page.
|
||||
// Only an item pulled before printing takes no printed position. An item
|
||||
// dropped from scoring after the exam was on the page and keeps its place,
|
||||
// so re-sealing after a drop describes the same paper the students held
|
||||
// rather than renumbering everything after it.
|
||||
let printed: Vec<Placement> = select::layout(record, form)
|
||||
.into_iter()
|
||||
.filter(|p| !p.dropped)
|
||||
.filter(|p| p.was_printed())
|
||||
.collect();
|
||||
|
||||
for (index, placement) in printed.iter().enumerate() {
|
||||
let entry = catalog.require(&placement.item)?;
|
||||
let item = &entry.item;
|
||||
let n = item.options.len();
|
||||
// The pool is not the paper: seal what this placement administered.
|
||||
let shown = item.administered(&placement.key, &placement.distractors);
|
||||
let n = shown.len();
|
||||
let order = select::option_order(form, &placement.item, n);
|
||||
|
||||
let canonical_key: BTreeSet<String> = if placement.key.is_empty() {
|
||||
@@ -541,8 +574,7 @@ fn sealed_form(catalog: &Catalog, record: &AssessmentFile, form: &Form) -> Resul
|
||||
let mut options = Vec::with_capacity(n);
|
||||
let mut printed_key = Vec::new();
|
||||
for (position, source_index) in order.iter().enumerate() {
|
||||
let canonical = item
|
||||
.options
|
||||
let canonical = shown
|
||||
.get(*source_index)
|
||||
.map(|c| c.id.clone())
|
||||
.unwrap_or_else(|| printed_letter(*source_index));
|
||||
@@ -637,6 +669,33 @@ impl SealFile {
|
||||
yaml::read(path)
|
||||
}
|
||||
|
||||
/// Loads every seal in a directory, oldest administration first.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `dir` - the seals directory.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The seals, empty when the directory does not exist.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates load failures, including a seal that does not parse.
|
||||
pub fn load_all(dir: &Path) -> Result<Vec<SealFile>> {
|
||||
let mut out = Vec::new();
|
||||
for path in crate::yaml::list_yaml(dir)? {
|
||||
out.push(SealFile::load(&path)?);
|
||||
}
|
||||
out.sort_by(|a, b| {
|
||||
a.seal
|
||||
.date
|
||||
.cmp(&b.seal.date)
|
||||
.then(a.seal.assessment.cmp(&b.seal.assessment))
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Loads the seal for an assessment, if one has been written.
|
||||
///
|
||||
/// Absence is not an error. A course that has never sealed anything should
|
||||
@@ -718,6 +777,12 @@ impl SealFile {
|
||||
self.schema_version, self.seal.assessment, self.seal.course, self.seal.term
|
||||
));
|
||||
for item in &self.items {
|
||||
// Appended only when present: a seal written before 2.0 has to keep
|
||||
// producing the input it was digested from, or every older
|
||||
// administration fails verification.
|
||||
if let Some(stem) = &item.stem_digest {
|
||||
buf.push_str(&format!("stem\u{1f}{}\u{1f}{stem}\n", item.number));
|
||||
}
|
||||
buf.push_str(&format!(
|
||||
"item\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}",
|
||||
item.number,
|
||||
@@ -743,6 +808,39 @@ impl SealFile {
|
||||
buf
|
||||
}
|
||||
|
||||
/// Renames the items this seal froze, keeping the digest honest.
|
||||
///
|
||||
/// The only sanctioned way to rewrite a seal. It recomputes the digest,
|
||||
/// because the ids are part of what the digest covers, and records the
|
||||
/// previous one in [`SealMeta::superseded_digests`], because a recomputed
|
||||
/// digest with no trace of the recompute is a record that claims never to
|
||||
/// have been touched.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `rename` - a map from old item id to new.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// How many placements were renamed. Zero leaves the file alone, digest
|
||||
/// included.
|
||||
pub fn rename_items(&mut self, rename: &BTreeMap<String, String>) -> usize {
|
||||
let mut renamed = 0;
|
||||
for item in &mut self.items {
|
||||
if let Some(new) = rename.get(&item.item) {
|
||||
item.item = new.clone();
|
||||
renamed += 1;
|
||||
}
|
||||
}
|
||||
if renamed == 0 {
|
||||
return 0;
|
||||
}
|
||||
let previous = std::mem::take(&mut self.seal.digest);
|
||||
self.seal.superseded_digests.push(previous);
|
||||
self.seal.digest = self.recompute_digest();
|
||||
renamed
|
||||
}
|
||||
|
||||
/// Recomputes this file's digest from its own contents.
|
||||
///
|
||||
/// # Returns
|
||||
@@ -903,7 +1001,7 @@ impl SealFile {
|
||||
),
|
||||
));
|
||||
}
|
||||
if was.learning_objectives != now.learning_objectives {
|
||||
if was.learning_targets != now.learning_targets {
|
||||
out.push(Drift::new(
|
||||
Severity::Low,
|
||||
"objectives-retagged",
|
||||
@@ -1151,16 +1249,18 @@ mod tests {
|
||||
content: true,
|
||||
dropped: Vec::new(),
|
||||
digest: String::new(),
|
||||
superseded_digests: Vec::new(),
|
||||
},
|
||||
items: vec![SealedItem {
|
||||
number: 1,
|
||||
item: "b::q-1".into(),
|
||||
version: Some(1),
|
||||
stem_digest: None,
|
||||
fingerprint: "abc".into(),
|
||||
points: 1.0,
|
||||
bonus: false,
|
||||
level: Some(Level::Remember),
|
||||
learning_objectives: vec!["lo-a".into()],
|
||||
learning_targets: vec!["lo-a".into()],
|
||||
key: vec!["B".into()],
|
||||
stem: Some("Stem.".into()),
|
||||
options: vec![
|
||||
@@ -1298,6 +1398,31 @@ mod tests {
|
||||
assert_eq!(short("sha256:0123456789abcdef"), "sha256:01234567");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_an_item_recomputes_the_digest_and_says_it_did() {
|
||||
let mut seal = sample();
|
||||
seal.seal.digest = seal.recompute_digest();
|
||||
let original = seal.seal.digest.clone();
|
||||
assert!(seal.check_self().is_empty());
|
||||
|
||||
let mut rename = BTreeMap::new();
|
||||
rename.insert(seal.items[0].item.clone(), "q-renamed".to_string());
|
||||
assert_eq!(seal.rename_items(&rename), 1);
|
||||
|
||||
// The digest covers the ids, so it has to move — and the file has to
|
||||
// admit that it moved rather than looking untouched.
|
||||
assert_eq!(seal.items[0].item, "q-renamed");
|
||||
assert_ne!(seal.seal.digest, original);
|
||||
assert_eq!(seal.seal.superseded_digests, vec![original]);
|
||||
assert!(seal.check_self().is_empty(), "{:?}", seal.check_self());
|
||||
|
||||
// A rename that matches nothing leaves the file entirely alone.
|
||||
let steady = seal.seal.digest.clone();
|
||||
assert_eq!(seal.rename_items(&BTreeMap::new()), 0);
|
||||
assert_eq!(seal.seal.digest, steady);
|
||||
assert_eq!(seal.seal.superseded_digests.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_yaml() {
|
||||
let file = sample();
|
||||
|
||||
@@ -9,6 +9,44 @@ use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Which tier of the objective registry an entry or a reported row belongs to.
|
||||
///
|
||||
/// The two words come from the assessment literature and name two different
|
||||
/// jobs, not two sizes of the same thing. A **learning objective** is what a
|
||||
/// syllabus lists and what a report classifies as met: the unit a claim is made
|
||||
/// about. A **learning target** is the specific performance an item is written
|
||||
/// against and tagged to: what a student aims at in one class, and the evidence
|
||||
/// a claim about an objective rests on.
|
||||
///
|
||||
/// It lives here rather than beside the registry because every layer that
|
||||
/// reports needs it, and a `bool` named for one of the two tiers leaves the
|
||||
/// reader guessing which way round it points.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Tier {
|
||||
/// A learning objective: the tier a mastery claim is made about.
|
||||
Objective,
|
||||
/// A learning target: the tier items are tagged to, and evidence for the
|
||||
/// objective above it.
|
||||
Target,
|
||||
}
|
||||
|
||||
impl Tier {
|
||||
/// The snake_case token used in YAML and in flat storage.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Tier::Objective => "objective",
|
||||
Tier::Target => "target",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Tier {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Cognitive demand, following the revised Bloom taxonomy.
|
||||
///
|
||||
/// Serialized as the integers 1 through 5 so YAML reads `level: 3`. The derived
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
//! replaces a dependency that would otherwise need to keep working for as long as
|
||||
//! a course repository needs to stay readable.
|
||||
|
||||
pub mod citation;
|
||||
pub mod date;
|
||||
pub mod hash;
|
||||
pub mod markup;
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! Pulling a citation apart when it was written as prose.
|
||||
//!
|
||||
//! A bibliography assembled by hand tends to collect entries like
|
||||
//!
|
||||
//! ```text
|
||||
//! note: 'Nucleic Acids Res 25:3389-3402. doi:10.1093/nar/25.17.3389'
|
||||
//! ```
|
||||
//!
|
||||
//! which is a complete citation in a field that means "anything else worth
|
||||
//! saying". Nothing can use it: a reading list cannot link the DOI, an export to
|
||||
//! Hayagriva or BibTeX has no journal to put in `parent` or `journal`, and the
|
||||
//! `container`, `volume`, `pages`, and `doi` fields sit empty beside it.
|
||||
//!
|
||||
//! [`parse`] takes such a note apart. What it cannot account for it leaves in
|
||||
//! the note, which is the important half of the contract: a note reading
|
||||
//! `'Bioinformatics 18:440-445. Origin of spaced seeds.'` yields the journal,
|
||||
//! the volume, the pages, and a note that still says where spaced seeds came
|
||||
//! from. Nothing is discarded and nothing is invented — an issue number that was
|
||||
//! never written down stays absent, even when a publisher's DOI happens to
|
||||
//! encode one.
|
||||
|
||||
/// The parts of a citation recovered from a note.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Parsed {
|
||||
/// The journal, proceedings, or book the work appeared in.
|
||||
pub container: Option<String>,
|
||||
/// The volume.
|
||||
pub volume: Option<String>,
|
||||
/// The page range, as `first-last`.
|
||||
pub pages: Option<String>,
|
||||
/// The DOI, bare.
|
||||
pub doi: Option<String>,
|
||||
/// What the note still says after the citation is removed.
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// Takes a citation apart, leaving the rest of the note alone.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `note` - the note as written.
|
||||
/// * `year` - the record's year, which is how a trailing year is recognized as
|
||||
/// part of a conference name rather than part of the title of the venue.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The parts found. Every field is independently optional: a note that carries
|
||||
/// only a DOI yields only a DOI.
|
||||
pub fn parse(note: &str, year: Option<u32>) -> Parsed {
|
||||
let mut out = Parsed::default();
|
||||
let mut rest = note.trim().to_string();
|
||||
|
||||
if let Some((container, volume, pages, tail)) = citation(&rest) {
|
||||
out.container = Some(container);
|
||||
out.volume = Some(volume);
|
||||
out.pages = Some(pages);
|
||||
rest = tail;
|
||||
}
|
||||
|
||||
if let Some((doi, tail)) = doi(&rest) {
|
||||
out.doi = Some(doi);
|
||||
rest = tail;
|
||||
}
|
||||
|
||||
// A venue with no volume or pages — a conference, usually — is named by the
|
||||
// clause that ends in the year the work was published.
|
||||
if out.container.is_none() {
|
||||
if let Some(y) = year {
|
||||
if let Some((container, tail)) = venue(&rest, y) {
|
||||
out.container = Some(container);
|
||||
rest = tail;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The year belongs to the record, not to the name of the venue.
|
||||
if let (Some(container), Some(y)) = (&out.container, year) {
|
||||
let suffix = format!(" {y}");
|
||||
if let Some(trimmed) = container.strip_suffix(&suffix) {
|
||||
out.container = Some(trimmed.trim_end().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let rest = rest.trim().trim_start_matches('.').trim().to_string();
|
||||
out.note = (!rest.is_empty()).then_some(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Finds `Journal 25:3389-3402` or `Journal 48, 443-453` at the start.
|
||||
///
|
||||
/// The volume is the first digit run that follows a space and is followed by a
|
||||
/// separator and a page range. Requiring the whole shape is what keeps a year in
|
||||
/// a conference name (`Proc. FOCS 2000.`) from being read as a volume.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The container, volume, page range, and whatever followed.
|
||||
fn citation(text: &str) -> Option<(String, String, String, String)> {
|
||||
let bytes = text.as_bytes();
|
||||
let mut at = 0;
|
||||
|
||||
while at < bytes.len() {
|
||||
// A volume follows a space, so that a digit inside a name is not one.
|
||||
if !(bytes[at].is_ascii_digit() && at > 0 && bytes[at - 1] == b' ') {
|
||||
at += 1;
|
||||
continue;
|
||||
}
|
||||
let volume_start = at;
|
||||
let volume_end = digits(bytes, volume_start);
|
||||
let mut cursor = spaces(bytes, volume_end);
|
||||
|
||||
// The separator between volume and pages is a colon or a comma.
|
||||
if cursor < bytes.len() && (bytes[cursor] == b':' || bytes[cursor] == b',') {
|
||||
cursor = spaces(bytes, cursor + 1);
|
||||
let first_start = cursor;
|
||||
let first_end = digits(bytes, first_start);
|
||||
if first_end > first_start {
|
||||
let dash = text[first_end..]
|
||||
.strip_prefix('-')
|
||||
.or_else(|| text[first_end..].strip_prefix('\u{2013}'));
|
||||
if let Some(after_dash) = dash {
|
||||
let last_offset = text.len() - after_dash.len();
|
||||
let last_end = digits(bytes, last_offset);
|
||||
if last_end > last_offset {
|
||||
let container = text[..volume_start].trim_end_matches([' ', ',']);
|
||||
if !container.is_empty() {
|
||||
return Some((
|
||||
container.to_string(),
|
||||
text[volume_start..volume_end].to_string(),
|
||||
format!(
|
||||
"{}-{}",
|
||||
&text[first_start..first_end],
|
||||
&text[last_offset..last_end]
|
||||
),
|
||||
text[last_end..].to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
at = volume_end;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Finds a `doi:10.…` anywhere in the text.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The DOI and the text with it removed.
|
||||
fn doi(text: &str) -> Option<(String, String)> {
|
||||
let lower = text.to_ascii_lowercase();
|
||||
let at = lower.find("doi:")?;
|
||||
let after = text[at + 4..].trim_start();
|
||||
let offset = text.len() - after.len();
|
||||
let end = after
|
||||
.find(char::is_whitespace)
|
||||
.map(|n| offset + n)
|
||||
.unwrap_or(text.len());
|
||||
|
||||
let doi = text[offset..end].trim_end_matches('.');
|
||||
if !doi.starts_with("10.") {
|
||||
return None;
|
||||
}
|
||||
let mut remainder = String::from(text[..at].trim_end());
|
||||
let tail = text[end..].trim();
|
||||
if !tail.is_empty() {
|
||||
if !remainder.is_empty() {
|
||||
remainder.push(' ');
|
||||
}
|
||||
remainder.push_str(tail);
|
||||
}
|
||||
Some((doi.to_string(), remainder))
|
||||
}
|
||||
|
||||
/// Finds a leading clause ending in the publication year: `Proc. FOCS 2000.`
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The clause without its trailing period, and whatever followed.
|
||||
fn venue(text: &str, year: u32) -> Option<(String, String)> {
|
||||
let needle = format!("{year}.");
|
||||
let at = text.find(&needle)?;
|
||||
let clause = text[..at + needle.len() - 1].trim();
|
||||
if clause.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
clause.to_string(),
|
||||
text[at + needle.len()..].trim().to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// The end of a run of ASCII digits starting at `from`.
|
||||
fn digits(bytes: &[u8], from: usize) -> usize {
|
||||
let mut at = from;
|
||||
while at < bytes.len() && bytes[at].is_ascii_digit() {
|
||||
at += 1;
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
/// The end of a run of spaces starting at `from`.
|
||||
fn spaces(bytes: &[u8], from: usize) -> usize {
|
||||
let mut at = from;
|
||||
while at < bytes.len() && bytes[at] == b' ' {
|
||||
at += 1;
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
/// Whether a note still looks like it is carrying a citation.
|
||||
///
|
||||
/// Used by validation to say so, rather than leaving a note that a reading list
|
||||
/// cannot link and an export cannot use.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `note` - the note as written.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` when a volume and page range, or a DOI, can be found in it.
|
||||
pub fn looks_like_a_citation(note: &str) -> bool {
|
||||
citation(note.trim()).is_some() || doi(note.trim()).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every article note in a real course bibliography, which is where the
|
||||
/// shapes below come from. Two separator styles, DOIs in three positions,
|
||||
/// a conference with no volume, and prose that has to survive.
|
||||
#[test]
|
||||
fn a_journal_citation_comes_apart() {
|
||||
let p = parse(
|
||||
"Nucleic Acids Res 25:3389-3402. doi:10.1093/nar/25.17.3389",
|
||||
Some(1997),
|
||||
);
|
||||
assert_eq!(p.container.as_deref(), Some("Nucleic Acids Res"));
|
||||
assert_eq!(p.volume.as_deref(), Some("25"));
|
||||
assert_eq!(p.pages.as_deref(), Some("3389-3402"));
|
||||
assert_eq!(p.doi.as_deref(), Some("10.1093/nar/25.17.3389"));
|
||||
assert_eq!(p.note, None);
|
||||
// The DOI encodes volume 25, issue 17. Nothing infers the issue from
|
||||
// it: a field nobody wrote down stays empty.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_abbreviation_keeps_its_final_period() {
|
||||
let p = parse("J. Mol. Biol. 48, 443-453.", Some(1970));
|
||||
assert_eq!(p.container.as_deref(), Some("J. Mol. Biol."));
|
||||
assert_eq!(p.volume.as_deref(), Some("48"));
|
||||
assert_eq!(p.pages.as_deref(), Some("443-453"));
|
||||
assert_eq!(p.note, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prose_after_a_citation_stays_in_the_note() {
|
||||
let p = parse(
|
||||
"Bioinformatics 18:440-445. Origin of spaced seeds.",
|
||||
Some(2002),
|
||||
);
|
||||
assert_eq!(p.container.as_deref(), Some("Bioinformatics"));
|
||||
assert_eq!(p.note.as_deref(), Some("Origin of spaced seeds."));
|
||||
|
||||
// A caveat the author wrote is the last thing to throw away.
|
||||
let p = parse("J Mol Biol 215:403-410. Verify before use.", Some(1990));
|
||||
assert_eq!(p.note.as_deref(), Some("Verify before use."));
|
||||
|
||||
let p = parse(
|
||||
"Bioinformatics 25:2078-2079. doi:10.1093/bioinformatics/btp352. Author list is the \
|
||||
core set plus the 1000 Genomes Data Processing Subgroup; verify.",
|
||||
Some(2009),
|
||||
);
|
||||
assert_eq!(p.doi.as_deref(), Some("10.1093/bioinformatics/btp352"));
|
||||
assert_eq!(
|
||||
p.note.as_deref(),
|
||||
Some(
|
||||
"Author list is the core set plus the 1000 Genomes Data Processing Subgroup; \
|
||||
verify."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conference_has_a_year_where_a_volume_would_be() {
|
||||
let p = parse(
|
||||
"Proc. FOCS 2000. doi:10.1109/SFCS.2000.892127. The FM-index. Theory background.",
|
||||
Some(2000),
|
||||
);
|
||||
assert_eq!(p.container.as_deref(), Some("Proc. FOCS"));
|
||||
// No volume and no pages were written, so none are invented — and the
|
||||
// 2000 in the DOI is not mistaken for either.
|
||||
assert_eq!(p.volume, None);
|
||||
assert_eq!(p.pages, None);
|
||||
assert_eq!(p.doi.as_deref(), Some("10.1109/SFCS.2000.892127"));
|
||||
assert_eq!(p.note.as_deref(), Some("The FM-index. Theory background."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_note_with_nothing_to_find_is_left_whole() {
|
||||
let p = parse("Origin of the MAPQ score.", Some(2008));
|
||||
assert_eq!(p.container, None);
|
||||
assert_eq!(p.note.as_deref(), Some("Origin of the MAPQ score."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multi_word_journal_is_not_cut_at_a_number() {
|
||||
let p = parse("Advances in Mathematics 20, 367-387.", Some(1976));
|
||||
assert_eq!(p.container.as_deref(), Some("Advances in Mathematics"));
|
||||
assert_eq!(p.volume.as_deref(), Some("20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_validation_looks_for() {
|
||||
assert!(looks_like_a_citation("Nat Methods 12:59-60."));
|
||||
assert!(looks_like_a_citation("doi:10.1038/nmeth.3176"));
|
||||
assert!(!looks_like_a_citation("Origin of minimizers."));
|
||||
assert!(!looks_like_a_citation("Verify before use."));
|
||||
// A page range with no volume is not a citation shape.
|
||||
assert!(!looks_like_a_citation("see pages 12-14"));
|
||||
}
|
||||
}
|
||||
+301
-4
@@ -150,7 +150,9 @@ pub fn to_markdown(src: &str) -> String {
|
||||
/// rather than naming a symbol, so `\Delta`, `\times`, `\ln` compile without
|
||||
/// error and print wrong. Each `$...$` span is instead handed whole to
|
||||
/// mitex's `mi`, which parses LaTeX grammar on purpose: `$\phi$` becomes
|
||||
/// `#mi("\\phi")`. The `@`/`<`/`>` escaping above is skipped for anything
|
||||
/// `#mi("\\phi")`. mhchem's `\ce{...}` is the one exception, and goes to
|
||||
/// whalogen's `ce` instead, via `push_math_span`. The `@`/`<`/`>` escaping
|
||||
/// above is skipped for anything
|
||||
/// inside the span, since it reaches Typst as a string argument, not as
|
||||
/// markup — escaping `<` there would corrupt the LaTeX rather than protect
|
||||
/// anything.
|
||||
@@ -174,6 +176,26 @@ pub fn to_typst(src: &str) -> String {
|
||||
while i < chars.len() {
|
||||
let (_, c) = chars[i];
|
||||
|
||||
// A `\ce{...}` written outside math is still chemistry and still cannot
|
||||
// reach Typst as a backslash: `\c` is an escape in content mode. Checked
|
||||
// before the escaped-pair rule below, which would otherwise copy `\c`
|
||||
// through and leave `e{...}` as literal text. The cheap two-character
|
||||
// guard keeps a stem full of `\Delta` and `\times` from rescanning the
|
||||
// remainder at every backslash.
|
||||
if c == '\\'
|
||||
&& chars.get(i + 1).map(|c| c.1) == Some('c')
|
||||
&& chars.get(i + 2).map(|c| c.1) == Some('e')
|
||||
{
|
||||
if let Some(span) = find_ce(&src[chars[i].0..]) {
|
||||
if span.start == 0 {
|
||||
let at = chars[i].0;
|
||||
push_ce(&mut out, &src[at + span.arg.0..at + span.arg.1]);
|
||||
i = char_index(&chars, at + span.end);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An escaped pair is copied verbatim and never reconsidered, so `\$`
|
||||
// can't be mistaken for the start of math and an `\@`/`\<`/`\>` an
|
||||
// author already wrote is not escaped a second time.
|
||||
@@ -189,9 +211,7 @@ pub fn to_typst(src: &str) -> String {
|
||||
Some(close) => {
|
||||
let start = chars[i + 1].0;
|
||||
let end = chars[close].0;
|
||||
out.push_str("#mi(");
|
||||
push_typst_string(&mut out, &src[start..end]);
|
||||
out.push(')');
|
||||
push_math_span(&mut out, &src[start..end]);
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
@@ -229,6 +249,190 @@ fn find_math_close(chars: &[(usize, char)], open: usize) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The import line a template needs before it can receive a `#ce(...)` call.
|
||||
///
|
||||
/// mitex renders the LaTeX in a stem, but it ships no package support, so
|
||||
/// mhchem's `\ce` is simply an unknown command to it. whalogen is a Typst port
|
||||
/// of mhchem, and `ce` is the function this module emits chemistry as.
|
||||
pub const CHEM_IMPORT: &str = "#import \"@preview/whalogen:0.3.0\": ce";
|
||||
|
||||
/// Whether `body` needs [`CHEM_IMPORT`] and `template` does not provide it.
|
||||
///
|
||||
/// A template is checked for the package name rather than for the exact import
|
||||
/// line, so a template that pins a different whalogen version, imports the
|
||||
/// package under an alias, or defines its own `ce` is left alone.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `template` - the template source the body will be injected into.
|
||||
/// * `body` - the generated Typst source.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Whether the pair would fail to compile for want of the import.
|
||||
pub fn needs_chem_import(template: &str, body: &str) -> bool {
|
||||
body.contains("#ce(") && !template.contains("whalogen") && !template.contains("let ce")
|
||||
}
|
||||
|
||||
/// A `\ce{...}` command located in a source fragment, as byte offsets from the
|
||||
/// start of that fragment.
|
||||
struct CeSpan {
|
||||
/// Offset of the backslash.
|
||||
start: usize,
|
||||
/// Offset just past the closing brace.
|
||||
end: usize,
|
||||
/// The argument, without its braces.
|
||||
arg: (usize, usize),
|
||||
}
|
||||
|
||||
/// Finds the first `\ce{...}` in `s`.
|
||||
///
|
||||
/// Three details matter, and all three come from the same place: the scan has to
|
||||
/// agree with what LaTeX itself would read.
|
||||
///
|
||||
/// A backslash-escaped pair is skipped as a unit, so the line break `\\`
|
||||
/// followed by the letters `ce` is not read as the command. `$a \\ ce{x}$` is a
|
||||
/// break and then literal text; mhchem's own command is a single backslash.
|
||||
///
|
||||
/// The name must end at the `e`, so `\cellcolor` and `\century` are left for
|
||||
/// mitex rather than half-consumed here.
|
||||
///
|
||||
/// The argument is matched on brace depth rather than on the first `}`, so
|
||||
/// `\ce{Fe^{2+}}` keeps its superscript. An unbalanced argument yields `None`:
|
||||
/// there is nothing safe to convert, and mitex's own error names the line.
|
||||
fn find_ce(s: &str) -> Option<CeSpan> {
|
||||
let chars: Vec<(usize, char)> = s.char_indices().collect();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if chars[i].1 != '\\' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if matches!(chars.get(i + 1), Some((_, '\\'))) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
let named = chars.get(i + 1).map(|c| c.1) == Some('c')
|
||||
&& chars.get(i + 2).map(|c| c.1) == Some('e')
|
||||
&& !matches!(chars.get(i + 3), Some((_, c)) if c.is_ascii_alphabetic());
|
||||
if !named {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// LaTeX allows whitespace between a control word and its argument.
|
||||
let mut open = i + 3;
|
||||
while matches!(chars.get(open), Some((_, c)) if c.is_whitespace()) {
|
||||
open += 1;
|
||||
}
|
||||
if !matches!(chars.get(open), Some((_, '{'))) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut depth = 1usize;
|
||||
let mut k = open + 1;
|
||||
while k < chars.len() {
|
||||
match chars[k].1 {
|
||||
'\\' => k += 2,
|
||||
'{' => {
|
||||
depth += 1;
|
||||
k += 1;
|
||||
}
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(CeSpan {
|
||||
start: chars[i].0,
|
||||
end: chars[k].0 + 1,
|
||||
arg: (chars[open].0 + 1, chars[k].0),
|
||||
});
|
||||
}
|
||||
k += 1;
|
||||
}
|
||||
_ => k += 1,
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The index into `chars` of the entry at byte offset `byte`, or the length when
|
||||
/// the offset is past the end.
|
||||
fn char_index(chars: &[(usize, char)], byte: usize) -> usize {
|
||||
chars
|
||||
.iter()
|
||||
.position(|(at, _)| *at >= byte)
|
||||
.unwrap_or(chars.len())
|
||||
}
|
||||
|
||||
/// Emits one `$...$` span as Typst content.
|
||||
///
|
||||
/// Most of a span goes to mitex's `mi`, which parses LaTeX on purpose. The
|
||||
/// exception is mhchem: `\ce{...}` is not a LaTeX primitive but a package
|
||||
/// command with its own character-level grammar, and mitex implements no
|
||||
/// packages, so the plugin aborts the whole document with `unknown command:
|
||||
/// \ce` rather than degrading to something printable. Those runs are handed to
|
||||
/// whalogen's `ce` instead and the rest of the span still goes to `mi`, so an
|
||||
/// equation that mixes chemistry with ordinary math renders both.
|
||||
///
|
||||
/// A span with no chemistry in it produces exactly one `mi` call, as it always
|
||||
/// has.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `out` - the buffer to append to.
|
||||
/// * `latex` - the LaTeX between the dollar signs.
|
||||
fn push_math_span(out: &mut String, latex: &str) {
|
||||
if find_ce(latex).is_none() {
|
||||
push_mi(out, latex);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut rest = latex;
|
||||
while let Some(span) = find_ce(rest) {
|
||||
push_run(out, &rest[..span.start]);
|
||||
push_ce(out, &rest[span.arg.0..span.arg.1]);
|
||||
rest = &rest[span.end..];
|
||||
}
|
||||
push_run(out, rest);
|
||||
}
|
||||
|
||||
/// Emits a non-chemistry run of a math span, dropping an empty one and keeping a
|
||||
/// whitespace-only one as the single space it separates two calls with.
|
||||
fn push_run(out: &mut String, latex: &str) {
|
||||
if latex.is_empty() {
|
||||
return;
|
||||
}
|
||||
if latex.trim().is_empty() {
|
||||
out.push(' ');
|
||||
return;
|
||||
}
|
||||
push_mi(out, latex);
|
||||
}
|
||||
|
||||
/// Emits a `#mi(...)` call wrapping LaTeX math.
|
||||
fn push_mi(out: &mut String, latex: &str) {
|
||||
out.push_str("#mi(");
|
||||
push_typst_string(out, latex);
|
||||
out.push(')');
|
||||
}
|
||||
|
||||
/// Emits a `#ce(...)` call wrapping an mhchem argument.
|
||||
///
|
||||
/// The argument is passed through as written. whalogen reads the same formula,
|
||||
/// charge, bond, and arrow syntax mhchem does, so `H2O`, `<=>`, `[AgCl2]-`, and
|
||||
/// `->[H2O]` need no translation. Its isotope and oxidation-number spellings do
|
||||
/// differ (`@Th,227,90@` against mhchem's `^{227}_{90}Th`), and so do `\pu` and
|
||||
/// `\bond`, which whalogen has no equivalent for. Those print oddly rather than
|
||||
/// failing the build, so a bank that uses them needs its own pass.
|
||||
fn push_ce(out: &mut String, argument: &str) {
|
||||
out.push_str("#ce(");
|
||||
push_typst_string(out, argument.trim());
|
||||
out.push(')');
|
||||
}
|
||||
|
||||
/// Writes `s` as a quoted Typst string. Kept local, duplicating the five-case
|
||||
/// match in `typst::value::write_string`, rather than reaching into the
|
||||
/// Typst-specific value writer for one small helper.
|
||||
@@ -529,3 +733,96 @@ fn escaped_and_unmatched_dollar_signs_are_left_or_escaped() {
|
||||
assert_eq!(to_typst("costs \\$5 total"), "costs \\$5 total");
|
||||
assert_eq!(to_typst("just $5"), "just \\$5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mhchem_goes_to_whalogen_rather_than_mitex() {
|
||||
// The regression: mitex implements no LaTeX packages, so `\ce` reached the
|
||||
// plugin as an unknown command and failed the whole document rather than
|
||||
// printing badly.
|
||||
assert_eq!(to_typst("$\\ce{H2O}$"), "#ce(\"H2O\")");
|
||||
assert_eq!(
|
||||
to_typst("water ionizes, $\\ce{H2O <=> H+ + OH-}$, giving"),
|
||||
"water ionizes, #ce(\"H2O <=> H+ + OH-\"), giving"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_span_can_mix_chemistry_with_ordinary_math() {
|
||||
// The chemistry leaves the span and the rest of it still reaches mitex, so
|
||||
// an equation that needs both renders both.
|
||||
assert_eq!(to_typst("$K_w = \\ce{H2O}$"), "#mi(\"K_w = \")#ce(\"H2O\")");
|
||||
// The space between the two runs stays inside the `mi` call rather than
|
||||
// becoming content-mode whitespace, so the spacing is TeX's to decide.
|
||||
assert_eq!(
|
||||
to_typst("$\\ce{H2O} \\to \\Delta H$"),
|
||||
"#ce(\"H2O\")#mi(\" \\\\to \\\\Delta H\")"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mhchem_arguments_keep_their_nested_braces() {
|
||||
// Matched on brace depth, not on the first `}`, or the charge is orphaned
|
||||
// and the remaining `}` closes the `#ce(` call early.
|
||||
assert_eq!(to_typst("$\\ce{Fe^{2+}}$"), "#ce(\"Fe^{2+}\")");
|
||||
assert_eq!(
|
||||
to_typst("$\\ce{SO4^{2-} + Ba^{2+}}$"),
|
||||
"#ce(\"SO4^{2-} + Ba^{2+}\")"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_latex_line_break_is_not_read_as_the_chemistry_command() {
|
||||
// `\\` is an escaped backslash followed by the letters `ce`, not `\ce`.
|
||||
// Scanning that skips escaped pairs as a unit keeps the two apart; scanning
|
||||
// that does not would convert a line break into a formula.
|
||||
assert_eq!(to_typst("$a \\\\ce{x}$"), "#mi(\"a \\\\\\\\ce{x}\")");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_that_merely_start_with_ce_are_left_to_mitex() {
|
||||
// The name has to end at the `e`, or `\cellcolor` is half-consumed and the
|
||||
// conversion invents a formula out of its argument.
|
||||
assert_eq!(
|
||||
to_typst("$\\cellcolor{red} x$"),
|
||||
"#mi(\"\\\\cellcolor{red} x\")"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbalanced_chemistry_is_left_for_mitex_to_report() {
|
||||
// Nothing safe to convert. mitex's own error names the file and line, which
|
||||
// is more useful than a silently truncated formula.
|
||||
assert_eq!(to_typst("$\\ce{H2O$"), "#mi(\"\\\\ce{H2O\")");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chemistry_outside_math_is_converted_too() {
|
||||
// A bare `\ce` never reaches mitex at all, and `\c` is an escape in Typst
|
||||
// content mode, so leaving it alone produces a document that either fails
|
||||
// or prints the letters.
|
||||
assert_eq!(
|
||||
to_typst("the backbone \\ce{-NH} group"),
|
||||
"the backbone #ce(\"-NH\") group"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_template_without_the_chemistry_import_is_named() {
|
||||
let body = "#question((stem: [#ce(\"H2O\")]))";
|
||||
assert!(needs_chem_import(
|
||||
"#import \"@preview/mitex:0.2.7\": mi",
|
||||
body
|
||||
));
|
||||
// An import of any whalogen version, or a template's own `ce`, is enough.
|
||||
assert!(!needs_chem_import(CHEM_IMPORT, body));
|
||||
assert!(!needs_chem_import(
|
||||
"#import \"@preview/whalogen:0.2.0\": ce as ce",
|
||||
body
|
||||
));
|
||||
assert!(!needs_chem_import("#let ce(f) = f", body));
|
||||
// No chemistry in the body, nothing to warn about.
|
||||
assert!(!needs_chem_import(
|
||||
"#import \"@preview/mitex:0.2.7\": mi",
|
||||
"#mi(\"x\")"
|
||||
));
|
||||
}
|
||||
|
||||
+48
-1
@@ -16,7 +16,7 @@ use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::de::{self, DeserializeOwned, Visitor};
|
||||
use serde::{Deserializer, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
@@ -61,6 +61,26 @@ pub fn write<T: Serialize>(path: &Path, value: &T) -> Result<()> {
|
||||
fs::write(path, text).map_err(|e| Error::io(path, e))
|
||||
}
|
||||
|
||||
/// Serializes a value to a YAML string.
|
||||
///
|
||||
/// Used where the caller needs to put something in front of the document, such
|
||||
/// as the banner on a generated file.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `value` - the value to serialize.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The YAML text.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Other`] if the value cannot be represented as YAML.
|
||||
pub fn to_string<T: Serialize>(value: &T) -> Result<String> {
|
||||
serde_yaml_ng::to_string(value).map_err(Error::other)
|
||||
}
|
||||
|
||||
/// Deserializes a JSON file into any type.
|
||||
///
|
||||
/// Used only for importing legacy banks and for reading emitted schemas back in
|
||||
@@ -202,6 +222,33 @@ where
|
||||
d.deserialize_any(V)
|
||||
}
|
||||
|
||||
/// Deserializes an optional scalar as a string, quoted or not.
|
||||
///
|
||||
/// The [`flexible_string`] of a field that may be absent, which is what a
|
||||
/// fragment's `schema_version` is: one file in a course declares it and the
|
||||
/// rest inherit.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `d` - the deserializer.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The value as a string, or `None`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a deserialization error for non-scalar input.
|
||||
pub fn flexible_string_opt<'de, D>(d: D) -> std::result::Result<Option<String>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Wrapper(#[serde(deserialize_with = "flexible_string")] String);
|
||||
|
||||
Ok(Option::<Wrapper>::deserialize(d)?.map(|w| w.0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user