Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6d6ee10b6
|
+105
-41
@@ -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, 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)]
|
||||
@@ -615,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.
|
||||
|
||||
+127
-10
@@ -37,6 +37,10 @@ pub enum Kind {
|
||||
Lecture,
|
||||
/// `objectives/*.yaml`.
|
||||
Objective,
|
||||
/// `analysis/calibration.yaml`.
|
||||
Calibration,
|
||||
/// `analysis/administrations/*.yaml`.
|
||||
Measurements,
|
||||
/// `banks/*.yaml`.
|
||||
Bank,
|
||||
/// `assessments/*.yaml`.
|
||||
@@ -45,13 +49,15 @@ pub enum Kind {
|
||||
|
||||
impl Kind {
|
||||
/// Every kind.
|
||||
pub const ALL: [Kind; 6] = [
|
||||
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.
|
||||
@@ -61,6 +67,8 @@ impl Kind {
|
||||
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",
|
||||
}
|
||||
@@ -99,6 +107,8 @@ pub fn schema(kind: Kind) -> Value {
|
||||
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(),
|
||||
}
|
||||
@@ -381,9 +391,12 @@ fn target_schema() -> Value {
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Position among the other targets of the same objective, low \
|
||||
first. Ordered within its objective rather than across the \
|
||||
course, so inserting one renumbers nothing outside its group."
|
||||
"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(
|
||||
@@ -412,8 +425,10 @@ fn objective_schema() -> Value {
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Position in teaching order, low first. Without it objectives \
|
||||
sort by id, which puts one 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(
|
||||
@@ -1058,6 +1073,106 @@ fn option_history_schema() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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!({
|
||||
@@ -1080,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",
|
||||
@@ -1149,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",
|
||||
|
||||
+39
@@ -137,6 +137,37 @@ pub(crate) enum MigrateCommand {
|
||||
#[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
|
||||
@@ -724,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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+106
-1
@@ -35,10 +35,15 @@ 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
|
||||
|
||||
@@ -109,9 +114,105 @@ pub(crate) fn migrate(cli: &Cli, sub: &MigrateCommand) -> Result<Outcome> {
|
||||
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)?;
|
||||
@@ -590,6 +691,10 @@ 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!(
|
||||
|
||||
+4
-2
@@ -106,9 +106,11 @@ 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;
|
||||
|
||||
|
||||
+634
-5
@@ -19,6 +19,9 @@
|
||||
//! | [`options_plan`] and [`apply_options`] | option letters into names | a letter is a position, not an identity |
|
||||
//! | [`stems`] | drops `version:` and `history:` | a stem's text is its identity |
|
||||
//! | [`store_variants`] | fills the stored `variant` column | so other readers can group by option set |
|
||||
//! | [`references`] | prose notes into citation fields | a DOI in a note is a link nobody can follow |
|
||||
//! | [`order`] | removes the `order:` integers | objective order is derived, and targets have none |
|
||||
//! | [`counters`] | drops the `-001` from item ids | a per-bank sequence number is not part of a name |
|
||||
//!
|
||||
//! # What the option migration does not touch
|
||||
//!
|
||||
@@ -96,7 +99,7 @@ pub struct Plan {
|
||||
|
||||
impl Plan {
|
||||
/// The number of lines each planned file holds.
|
||||
pub fn lines(&self) -> Vec<(PathBuf, usize)> {
|
||||
pub fn lines(&self) -> Report {
|
||||
self.files
|
||||
.iter()
|
||||
.map(|f| (f.path.clone(), f.text.lines().count()))
|
||||
@@ -602,7 +605,7 @@ fn unqualify_line(line: &str) -> String {
|
||||
///
|
||||
/// Propagates read and write failures, including
|
||||
/// [`Error::FeatureDisabled`] when the store is Parquet and the feature is off.
|
||||
pub fn store_ids(root: &Path, write: bool) -> Result<Vec<(PathBuf, usize)>> {
|
||||
pub fn store_ids(root: &Path, write: bool) -> Result<Report> {
|
||||
let layout = Layout::new(root);
|
||||
let store = Store::open(layout.data())?;
|
||||
let mut out = Vec::new();
|
||||
@@ -629,6 +632,15 @@ pub fn store_ids(root: &Path, write: bool) -> Result<Vec<(PathBuf, usize)>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One file a migration changed, and how many things in it changed.
|
||||
pub type Changed = (PathBuf, usize);
|
||||
|
||||
/// What a migration changed, file by file.
|
||||
pub type Report = Vec<Changed>;
|
||||
|
||||
/// Old id to new id, for the migrations that rename things.
|
||||
pub type Renames = BTreeMap<String, String>;
|
||||
|
||||
/// The per-item map from a pre-2.0 option letter to the name that replaces it.
|
||||
pub type OptionMap = BTreeMap<String, BTreeMap<String, String>>;
|
||||
|
||||
@@ -844,7 +856,7 @@ pub fn options_plan(root: &Path) -> Result<(OptionMap, Vec<String>)> {
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates read and write failures.
|
||||
pub fn apply_options(root: &Path, map: &OptionMap, write: bool) -> Result<Vec<(PathBuf, usize)>> {
|
||||
pub fn apply_options(root: &Path, map: &OptionMap, write: bool) -> Result<Report> {
|
||||
let layout = Layout::new(root);
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -1132,7 +1144,7 @@ fn key_at_word(line: &str, key: &str) -> Option<usize> {
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates read and write failures.
|
||||
pub fn stems(root: &Path, write: bool) -> Result<Vec<(PathBuf, usize)>> {
|
||||
pub fn stems(root: &Path, write: bool) -> Result<Report> {
|
||||
let layout = Layout::new(root);
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -1221,7 +1233,7 @@ fn is_key(trimmed: &str, key: &str) -> bool {
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates catalog, record, and store failures.
|
||||
pub fn store_variants(root: &Path, write: bool) -> Result<Vec<(PathBuf, usize)>> {
|
||||
pub fn store_variants(root: &Path, write: bool) -> Result<Report> {
|
||||
let layout = Layout::new(root);
|
||||
let catalog = crate::catalog::Catalog::load(root)?;
|
||||
|
||||
@@ -1265,6 +1277,475 @@ pub fn store_variants(root: &Path, write: bool) -> Result<Vec<(PathBuf, usize)>>
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Drops the trailing counter from every item id.
|
||||
///
|
||||
/// `q-fastq-quality-line-001` becomes `q-fastq-quality-line`. The counter was a
|
||||
/// per-bank sequence number, which says when an item was written — something git
|
||||
/// knows — and collides with another bank's numbering the moment an item moves.
|
||||
///
|
||||
/// This is a rename, not a normalization, and that distinction decides how it
|
||||
/// has to be done. Stripping `bank::` from an id recovered a name that was
|
||||
/// already inside it, so an old reference could simply be read as the new one.
|
||||
/// A counter carries no such fallback: `q-x-001` and `q-x` are two unrelated
|
||||
/// strings, and every file that names one has to be rewritten in the same pass
|
||||
/// or the join to four terms of response data quietly breaks.
|
||||
///
|
||||
/// So: banks, assessment records, seals, and the response store, or nothing.
|
||||
/// Two ids that would collide after stripping abort the whole migration rather
|
||||
/// than merging two questions into one.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `root` - the course directory.
|
||||
/// * `write` - whether to write, or only to count.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The renames, and one entry per file that changes with how many references it
|
||||
/// holds.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Invalid`] when two ids would collide, listing both, and
|
||||
/// propagates load and write failures.
|
||||
pub fn counters(root: &Path, write: bool) -> Result<(Renames, Report)> {
|
||||
let layout = Layout::new(root);
|
||||
let catalog = crate::catalog::Catalog::load(root)?;
|
||||
|
||||
let mut rename: Renames = BTreeMap::new();
|
||||
let mut taken: Renames = BTreeMap::new();
|
||||
let mut collisions = Vec::new();
|
||||
for entry in &catalog.entries {
|
||||
let Some(bare) = strip_counter(&entry.uid) else {
|
||||
taken.insert(entry.uid.clone(), entry.uid.clone());
|
||||
continue;
|
||||
};
|
||||
if let Some(first) = taken.get(&bare) {
|
||||
collisions.push(format!(
|
||||
"`{}` and `{first}` would both become `{bare}`. Give one of them a name that \
|
||||
says what it asks rather than when it was written.",
|
||||
entry.uid
|
||||
));
|
||||
continue;
|
||||
}
|
||||
taken.insert(bare.clone(), entry.uid.clone());
|
||||
rename.insert(entry.uid.clone(), bare);
|
||||
}
|
||||
if !collisions.is_empty() {
|
||||
return Err(Error::Invalid(collisions));
|
||||
}
|
||||
if rename.is_empty() {
|
||||
return Ok((rename, Vec::new()));
|
||||
}
|
||||
|
||||
let mut touched = Vec::new();
|
||||
|
||||
// Banks: the definition, and any `supersedes` pointing at one.
|
||||
for path in yaml::list_yaml(&layout.banks())? {
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
|
||||
let (out, n) = rename_ids(&text, &rename, &["- id", "supersedes"]);
|
||||
if n > 0 {
|
||||
if write {
|
||||
yaml::write_text(&path, &out)?;
|
||||
}
|
||||
touched.push((relative(root, &path), n));
|
||||
}
|
||||
}
|
||||
|
||||
// Records: the `item:` on every placement.
|
||||
for path in yaml::list_yaml(&layout.assessments())? {
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
|
||||
let (out, n) = rename_ids(&text, &rename, &["item"]);
|
||||
if n > 0 {
|
||||
if write {
|
||||
yaml::write_text(&path, &out)?;
|
||||
}
|
||||
touched.push((relative(root, &path), n));
|
||||
}
|
||||
}
|
||||
|
||||
// Seals go through the model, not the text, because the ids are inside the
|
||||
// digest and a seal that has been rewritten has to say so.
|
||||
for path in yaml::list_yaml(&layout.seals())? {
|
||||
let mut seal = crate::seal::SealFile::load(&path)?;
|
||||
let n = seal.rename_items(&rename);
|
||||
if n > 0 {
|
||||
if write {
|
||||
seal.save(&path)?;
|
||||
}
|
||||
touched.push((relative(root, &path), n));
|
||||
}
|
||||
}
|
||||
|
||||
// The store, where the id is the join key.
|
||||
let store = Store::open(layout.data())?;
|
||||
for path in store.files()? {
|
||||
let mut rows = store::read_flat(&path)?;
|
||||
let mut n = 0;
|
||||
for row in &mut rows {
|
||||
if let Some(new) = rename.get(&row.item_ref) {
|
||||
row.item_ref = new.clone();
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
if write {
|
||||
store::write_flat(&path, &rows)?;
|
||||
}
|
||||
touched.push((relative(root, &path), n));
|
||||
}
|
||||
}
|
||||
|
||||
Ok((rename, touched))
|
||||
}
|
||||
|
||||
/// An id with a trailing `-123` removed, or `None` when it has none.
|
||||
fn strip_counter(id: &str) -> Option<String> {
|
||||
let (head, tail) = id.rsplit_once('-')?;
|
||||
if head.is_empty() || tail.is_empty() || !tail.chars().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
Some(head.to_string())
|
||||
}
|
||||
|
||||
/// Rewrites the value of the named keys wherever it is an id being renamed.
|
||||
///
|
||||
/// Keyed on the field name so that an id appearing in prose — a rationale that
|
||||
/// mentions the item it replaced, a note — is left alone. A rename that edited
|
||||
/// every matching string in the file would also edit the sentences about it.
|
||||
fn rename_ids(text: &str, rename: &Renames, keys: &[&str]) -> (String, usize) {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut count = 0;
|
||||
|
||||
for line in text.lines() {
|
||||
let mut replaced = None;
|
||||
for key in keys {
|
||||
let Some(at) = field_value(line, key) else {
|
||||
continue;
|
||||
};
|
||||
let value = &line[at.0..at.1];
|
||||
// Canonicalized before the lookup, so a record that still carries a
|
||||
// pre-2.0 `bank::` qualifier is renamed rather than skipped. Left
|
||||
// alone it would keep pointing at an id the bank no longer has.
|
||||
let Some(new) = rename.get(crate::item::canonical_id(value)) else {
|
||||
continue;
|
||||
};
|
||||
replaced = Some(format!("{}{new}{}", &line[..at.0], &line[at.1..]));
|
||||
count += 1;
|
||||
break;
|
||||
}
|
||||
out.push(replaced.unwrap_or_else(|| line.to_string()));
|
||||
}
|
||||
|
||||
let mut joined = out.join("\n");
|
||||
if text.ends_with('\n') {
|
||||
joined.push('\n');
|
||||
}
|
||||
(joined, count)
|
||||
}
|
||||
|
||||
/// The byte range of a `key: value` scalar on one line, quotes excluded.
|
||||
fn field_value(line: &str, key: &str) -> Option<(usize, usize)> {
|
||||
let needle = format!("{key}:");
|
||||
let at = line.find(&needle)?;
|
||||
let before = &line[..at];
|
||||
if before.chars().next_back().is_some_and(|c| {
|
||||
c.is_ascii_alphanumeric() || c == '_' || (c == '-' && !key.starts_with('-'))
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
let rest = &line[at + needle.len()..];
|
||||
let lead = rest.len() - rest.trim_start().len();
|
||||
let value = rest.trim_start();
|
||||
let quoted = value.starts_with(['"', '\'']);
|
||||
let start = at + needle.len() + lead + usize::from(quoted);
|
||||
let inner = &line[start..];
|
||||
let end = inner
|
||||
.find(|c: char| {
|
||||
c == '"' || c == '\'' || c == ',' || c == '}' || c == ']' || c.is_whitespace()
|
||||
})
|
||||
.map(|n| start + n)
|
||||
.unwrap_or(line.len());
|
||||
(end > start).then_some((start, end))
|
||||
}
|
||||
|
||||
/// Removes the hand-kept `order:` integers.
|
||||
///
|
||||
/// Nothing is written in their place. An objective's teaching order is its
|
||||
/// position in a lecture's `teaches` list, which `migrate split` already
|
||||
/// produced. A target has no order at all — an objective's targets are a set of
|
||||
/// question templates, sampled from rather than worked through — so the integer
|
||||
/// was asserting a sequence that was never taught. Where a list has to be
|
||||
/// printed, [`crate::course::CourseFile::targets`] orders it by ceiling.
|
||||
///
|
||||
/// Two hundred and four integers in a real course, each of which had to be
|
||||
/// bumped by hand when a target was inserted in the middle.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `root` - the course directory.
|
||||
/// * `write` - whether to write, or only to count.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// One entry per file that changes, with how many `order:` lines it loses.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates catalog and write failures, and refuses when the lectures do not
|
||||
/// declare what they teach, since objective order would then have no source.
|
||||
pub fn order(root: &Path, write: bool) -> Result<Report> {
|
||||
let layout = Layout::new(root);
|
||||
let course = CourseFile::load_dir(root)?;
|
||||
|
||||
if course.lectures.values().all(|l| l.teaches.is_empty())
|
||||
&& !course.learning_objectives.is_empty()
|
||||
{
|
||||
return Err(Error::usage(
|
||||
"no lecture declares what it teaches, so objective order would have nothing to come from. Run `coursebank migrate split` first."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut files = vec![layout.course_file()];
|
||||
files.extend(yaml::list_yaml(&layout.objectives())?);
|
||||
for path in files {
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
|
||||
let (rewritten, dropped) = rewrite_order(&text);
|
||||
if rewritten == text {
|
||||
continue;
|
||||
}
|
||||
if write {
|
||||
yaml::write_text(&path, &rewritten)?;
|
||||
}
|
||||
out.push((relative(root, &path), dropped));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Removes every `order:` line.
|
||||
///
|
||||
/// Nothing replaces them. An objective's position comes from where its lecture
|
||||
/// lists it in `teaches`, and a target has no position to derive: an
|
||||
/// objective's targets are a set of question templates rather than steps in a
|
||||
/// sequence, so the integer was asserting an order that was never taught.
|
||||
fn rewrite_order(text: &str) -> (String, usize) {
|
||||
let mut out: Vec<&str> = Vec::new();
|
||||
let mut dropped = 0;
|
||||
|
||||
for line in text.lines() {
|
||||
if key_at(line, 4).as_deref() == Some("order") {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
let mut joined = out.join("\n");
|
||||
if text.ends_with('\n') {
|
||||
joined.push('\n');
|
||||
}
|
||||
(joined, dropped)
|
||||
}
|
||||
|
||||
/// Turns the citations written into `note:` fields into real fields.
|
||||
///
|
||||
/// A hand-built bibliography collects entries whose journal, volume, pages, and
|
||||
/// DOI are all sitting in the one field that means "anything else worth saying".
|
||||
/// Nothing can use them there: a reading list cannot link a DOI it cannot see,
|
||||
/// and an export to Hayagriva or BibTeX has no journal to put in `parent` or
|
||||
/// `journal`.
|
||||
///
|
||||
/// Textual, like the rest of this module, and for a reason specific to this
|
||||
/// file: a bibliography is usually ordered the way its author thinks about it —
|
||||
/// books, then the papers that matter — and the model holds it in a map, so a
|
||||
/// serde round trip would alphabetize all of it to change twenty entries.
|
||||
///
|
||||
/// A field the entry already declares is never overwritten. Where the note
|
||||
/// disagrees with it, the note's version is left in place for a person to look
|
||||
/// at rather than silently replacing something that was typed deliberately.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `root` - the course directory.
|
||||
/// * `write` - whether to write, or only to count.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// One entry per file that changes, with how many notes were taken apart, and
|
||||
/// one message per disagreement found.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates read and write failures.
|
||||
pub fn references(root: &Path, write: bool) -> Result<(Report, Vec<String>)> {
|
||||
let layout = Layout::new(root);
|
||||
let mut touched = Vec::new();
|
||||
let mut notes = Vec::new();
|
||||
|
||||
// Either file may hold the bibliography: its own, or the course file that
|
||||
// has not been split yet.
|
||||
for path in [layout.references_file(), layout.course_file()] {
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
|
||||
let (rewritten, count, said) = expand_notes(&text);
|
||||
notes.extend(said);
|
||||
if count > 0 {
|
||||
if write {
|
||||
yaml::write_text(&path, &rewritten)?;
|
||||
}
|
||||
touched.push((relative(root, &path), count));
|
||||
}
|
||||
}
|
||||
Ok((touched, notes))
|
||||
}
|
||||
|
||||
/// Replaces each reference's `note:` line with the fields it was carrying.
|
||||
fn expand_notes(text: &str) -> (String, usize, Vec<String>) {
|
||||
let lines: Vec<String> = text.lines().map(str::to_string).collect();
|
||||
let Some(section) = blocks(&lines, 0)
|
||||
.into_iter()
|
||||
.find(|b| b.key == "references")
|
||||
else {
|
||||
return (text.to_string(), 0, Vec::new());
|
||||
};
|
||||
|
||||
// Which lines belong to which entry, so a note is parsed with its own year
|
||||
// and checked against its own fields.
|
||||
let mut rewritten: BTreeMap<usize, Vec<String>> = BTreeMap::new();
|
||||
let mut said = Vec::new();
|
||||
let mut count = 0;
|
||||
|
||||
let start = lines.len() - section.lines.len();
|
||||
for entry in blocks(§ion.body(), 2) {
|
||||
let offset = lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(start)
|
||||
.find(|(_, l)| **l == entry.lines[0])
|
||||
.map(|(i, _)| i);
|
||||
let Some(offset) = offset else { continue };
|
||||
|
||||
let Some((note_at, note)) = entry
|
||||
.lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(i, l)| scalar(l, 4, "note").map(|v| (i, v)))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let year = entry
|
||||
.lines
|
||||
.iter()
|
||||
.find_map(|l| scalar(l, 4, "year"))
|
||||
.and_then(|v| v.parse::<u32>().ok());
|
||||
let parsed = crate::citation::parse(¬e, year);
|
||||
if parsed.container.is_none() && parsed.doi.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let declared = |field: &str| entry.lines.iter().any(|l| scalar(l, 4, field).is_some());
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (field, value, quote) in [
|
||||
("container", parsed.container.clone(), false),
|
||||
("volume", parsed.volume.clone(), true),
|
||||
("pages", parsed.pages.clone(), true),
|
||||
("doi", parsed.doi.clone(), true),
|
||||
] {
|
||||
let Some(value) = value else { continue };
|
||||
if declared(field) {
|
||||
said.push(format!(
|
||||
"`{}` already declares {field}; the note's `{value}` was left in place",
|
||||
entry.key
|
||||
));
|
||||
continue;
|
||||
}
|
||||
out.push(format!(" {field}: {}", render(&value, quote)));
|
||||
}
|
||||
if let Some(note) = &parsed.note {
|
||||
out.push(format!(" note: {}", render(note, true)));
|
||||
}
|
||||
|
||||
// A note whose every part was already declared is not a change.
|
||||
if out.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if said
|
||||
.iter()
|
||||
.any(|m| m.starts_with(&format!("`{}`", entry.key)))
|
||||
&& parsed.note.is_some()
|
||||
&& out.len() == 1
|
||||
{
|
||||
continue;
|
||||
}
|
||||
rewritten.insert(offset + note_at, out);
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return (text.to_string(), 0, said);
|
||||
}
|
||||
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
match rewritten.get(&i) {
|
||||
Some(replacement) => out.extend(replacement.clone()),
|
||||
None => out.push(line.clone()),
|
||||
}
|
||||
}
|
||||
let mut joined = out.join("\n");
|
||||
if text.ends_with('\n') {
|
||||
joined.push('\n');
|
||||
}
|
||||
(joined, count, said)
|
||||
}
|
||||
|
||||
/// The value of a `key: value` line at an exact indentation, unquoted.
|
||||
fn scalar(line: &str, indent: usize, key: &str) -> Option<String> {
|
||||
if key_at(line, indent).as_deref() != Some(key) {
|
||||
return None;
|
||||
}
|
||||
let value = line[indent + key.len() + 1..].trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
for quote in ['\'', '"'] {
|
||||
if let Some(inner) = value
|
||||
.strip_prefix(quote)
|
||||
.and_then(|v| v.strip_suffix(quote))
|
||||
{
|
||||
return Some(inner.replace("''", "'"));
|
||||
}
|
||||
}
|
||||
Some(value.to_string())
|
||||
}
|
||||
|
||||
/// A YAML scalar, quoted when it has to be.
|
||||
///
|
||||
/// Numbers are quoted whether they need it or not: `volume: 48` reads back as an
|
||||
/// integer and the field is a string, so the file would stop loading.
|
||||
fn render(value: &str, quote: bool) -> String {
|
||||
let risky = quote
|
||||
|| value.contains(": ")
|
||||
|| value.ends_with(':')
|
||||
|| value.contains(" #")
|
||||
|| value.starts_with([
|
||||
'[', '{', '&', '*', '!', '|', '>', '%', '@', '`', '\'', '"', '-',
|
||||
]);
|
||||
if risky {
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
} else {
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// One block of YAML: a key, the lines under it, and the comments above it.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct Block {
|
||||
@@ -1952,6 +2433,154 @@ items:
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_order_integers_are_dropped_and_nothing_replaces_them() {
|
||||
let text = r#"learning_objectives:
|
||||
lo-x:
|
||||
text: 'Read the formats.'
|
||||
unit: u1
|
||||
order: 2
|
||||
level_ceiling: 2
|
||||
|
||||
learning_targets:
|
||||
t-a:
|
||||
text: 'First.'
|
||||
objective: lo-x
|
||||
order: 1
|
||||
level_ceiling: 1
|
||||
t-b:
|
||||
text: 'Second.'
|
||||
objective: lo-x
|
||||
order: 2
|
||||
level_ceiling: 2
|
||||
"#;
|
||||
let (out, dropped) = rewrite_order(text);
|
||||
assert_eq!(dropped, 3, "one on the objective, one on each target");
|
||||
assert!(!out.contains("order:"), "{out}");
|
||||
// Nothing is written in their place: the objective's position comes
|
||||
// from its lecture's `teaches`, and a target has no position. Matched
|
||||
// on the inserted form rather than on `targets:`, which is a substring
|
||||
// of the `learning_targets:` section header.
|
||||
assert!(!out.contains("targets: ["), "{out}");
|
||||
// Everything else is untouched, prose and ceilings included.
|
||||
assert!(out.contains("text: 'Read the formats.'"), "{out}");
|
||||
assert!(out.contains("level_ceiling: 2"), "{out}");
|
||||
assert!(out.contains(" t-a:"), "{out}");
|
||||
|
||||
// Idempotent.
|
||||
let (again, dropped) = rewrite_order(&out);
|
||||
assert_eq!(dropped, 0);
|
||||
assert_eq!(again, out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_note_becomes_the_fields_it_was_carrying() {
|
||||
let text = r#"references:
|
||||
altschul1997gapped:
|
||||
label: GBLAST97
|
||||
kind: article
|
||||
role: supplemental
|
||||
title: 'Gapped BLAST and PSI-BLAST'
|
||||
year: 1997
|
||||
note: 'Nucleic Acids Res 25:3389-3402. doi:10.1093/nar/25.17.3389'
|
||||
brown2013next:
|
||||
label: BSM
|
||||
kind: book
|
||||
title: Next-generation DNA sequencing informatics
|
||||
year: 2013
|
||||
"#;
|
||||
let (out, count, said) = expand_notes(text);
|
||||
assert_eq!(count, 1);
|
||||
assert!(said.is_empty(), "{said:?}");
|
||||
assert!(out.contains(" container: Nucleic Acids Res"), "{out}");
|
||||
assert!(out.contains(" volume: '25'"), "{out}");
|
||||
assert!(out.contains(" pages: '3389-3402'"), "{out}");
|
||||
assert!(out.contains(" doi: '10.1093/nar/25.17.3389'"), "{out}");
|
||||
// Nothing left of the note, and nothing invented to replace it.
|
||||
assert!(!out.contains("note:"), "{out}");
|
||||
assert!(!out.contains("issue:"), "{out}");
|
||||
// The book had no note and is untouched, label and all.
|
||||
assert!(out.contains(" label: BSM"), "{out}");
|
||||
assert!(out.contains(" brown2013next:"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_already_declared_is_never_overwritten() {
|
||||
let text = r#"references:
|
||||
steinegger2017mmseqs2:
|
||||
kind: article
|
||||
title: MMseqs2
|
||||
year: 2017
|
||||
note: 'Nat Biotechnol 35:1026-1028. doi:10.1038/nbt.3988'
|
||||
doi: '10.1038/nbt.3988'
|
||||
"#;
|
||||
let (out, _, said) = expand_notes(text);
|
||||
assert!(
|
||||
said.iter().any(|m| m.contains("already declares doi")),
|
||||
"{said:?}"
|
||||
);
|
||||
// One doi line, the one that was typed deliberately.
|
||||
assert_eq!(out.matches("doi:").count(), 1, "{out}");
|
||||
assert!(out.contains(" container: Nat Biotechnol"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_counter_is_recognized_and_nothing_else_is() {
|
||||
assert_eq!(
|
||||
strip_counter("q-fastq-quality-line-001").as_deref(),
|
||||
Some("q-fastq-quality-line")
|
||||
);
|
||||
assert_eq!(
|
||||
strip_counter("q-blast-seed-14").as_deref(),
|
||||
Some("q-blast-seed")
|
||||
);
|
||||
// Already bare.
|
||||
assert_eq!(strip_counter("q-fastq-quality-line"), None);
|
||||
// A number that is part of the name, not a counter after it.
|
||||
assert_eq!(strip_counter("q-fastqc-3prime-decay"), None);
|
||||
assert_eq!(strip_counter("q-001"), Some("q".to_string()));
|
||||
assert_eq!(strip_counter("q-x-"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_touches_the_named_fields_and_leaves_prose_alone() {
|
||||
let mut rename = BTreeMap::new();
|
||||
rename.insert("q-x-001".to_string(), "q-x".to_string());
|
||||
|
||||
let bank = r#"items:
|
||||
- id: q-x-001
|
||||
supersedes: q-x-001
|
||||
stem: Which line?
|
||||
design:
|
||||
rationale: >-
|
||||
Replaces q-x-001, which had two defensible answers.
|
||||
"#;
|
||||
let (out, n) = rename_ids(bank, &rename, &["- id", "supersedes"]);
|
||||
assert_eq!(n, 2, "the id and the supersedes, not the sentence");
|
||||
assert!(out.contains(" - id: q-x\n"), "{out}");
|
||||
assert!(out.contains(" supersedes: q-x\n"), "{out}");
|
||||
// The rationale is prose about the item; editing it would rewrite the
|
||||
// sentence as well as the reference.
|
||||
assert!(out.contains("Replaces q-x-001, which had"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_that_never_dropped_its_bank_prefix_is_still_renamed() {
|
||||
let mut rename = BTreeMap::new();
|
||||
rename.insert("q-x-001".to_string(), "q-x".to_string());
|
||||
|
||||
let flow = " - { number: 1, item: \"b-1-2::q-x-001\", key: [o-a] }\n";
|
||||
let (out, n) = rename_ids(flow, &rename, &["item"]);
|
||||
assert_eq!(n, 1);
|
||||
// Left alone it would point at an id the bank no longer has.
|
||||
assert!(out.contains("item: \"q-x\""), "{out}");
|
||||
|
||||
let block = " item: q-x-001\n";
|
||||
let (out, n) = rename_ids(block, &rename, &["item"]);
|
||||
assert_eq!(n, 1);
|
||||
assert_eq!(out, " item: q-x\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lecture_slugs_match_the_house_naming() {
|
||||
assert_eq!(lecture_slug("L1.2"), "l-1-2");
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
pub mod assessment;
|
||||
pub mod bank;
|
||||
pub mod calibration;
|
||||
pub mod catalog;
|
||||
pub mod course;
|
||||
pub mod history;
|
||||
|
||||
+6
-32
@@ -530,38 +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 option in c.option_stats.keys() {
|
||||
if it.option(option).is_none() {
|
||||
issues.push(format!(
|
||||
"calibration.option_stats has `{option}`, 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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
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 -----
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -75,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>,
|
||||
}
|
||||
@@ -104,7 +110,10 @@ impl Catalog {
|
||||
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())?;
|
||||
@@ -145,6 +154,15 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
+69
-9
@@ -800,15 +800,25 @@ pub struct Objective {
|
||||
/// The lectures that develop it.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub lectures: Vec<String>,
|
||||
/// Position in teaching order, low first.
|
||||
/// Position in teaching order, low first. Derived; authoring it is
|
||||
/// deprecated.
|
||||
///
|
||||
/// The registry is a map, so declaration order is lost on load, and sorting
|
||||
/// The registry is a map, so declaration order is lost on load and sorting
|
||||
/// by id would put `lo-enthalpy` before `lo-first-law` when the second is a
|
||||
/// prerequisite of the first. Anything that prints objectives in the order
|
||||
/// you teach them, a lecture page above all, needs this. Objectives without
|
||||
/// it sort last, by id.
|
||||
/// prerequisite of the first. Something has to supply the order.
|
||||
///
|
||||
/// Since 2.0 that something is [`Lecture::teaches`], which is a sequence:
|
||||
/// the position of an objective in the list of what a lecture covers, and
|
||||
/// the position of the lecture in the course, together say when it is
|
||||
/// taught. [`fragment::assemble`] fills this in from those two, so a course
|
||||
/// that used to carry thirty-nine hand-kept integers now carries none, and
|
||||
/// inserting an objective is a one-line edit rather than a renumber.
|
||||
///
|
||||
/// An authored value still wins, so a 1.0 course loads unchanged.
|
||||
/// `coursebank migrate order` removes them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub order: Option<u32>,
|
||||
|
||||
/// The highest level you intend to assess this objective at. Assembling an
|
||||
/// item above the ceiling is a warning: either the item overreaches or the
|
||||
/// ceiling needs raising.
|
||||
@@ -865,8 +875,20 @@ pub struct Target {
|
||||
///
|
||||
/// Ordered within its objective rather than across the course, so two
|
||||
/// targets under different objectives never compete for a position and
|
||||
/// inserting one renumbers nothing outside its own group.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
/// Retained only so a pre-2.0 course still loads. Ignored.
|
||||
///
|
||||
/// Targets do not have an order. An objective's targets are a set of
|
||||
/// question templates, not steps in a sequence: they are not taught in
|
||||
/// order, an exam samples from them rather than working through them, and
|
||||
/// the study workflow reads the list as a checklist and counts what it can
|
||||
/// do cold. A position would assert a sequence that does not exist.
|
||||
///
|
||||
/// Where a list has to be printed, [`CourseFile::targets`] orders it by
|
||||
/// ceiling and then by id. Where one target genuinely depends on another,
|
||||
/// that is [`Target::prerequisites`], which says so directly.
|
||||
///
|
||||
/// `coursebank migrate order` removes it.
|
||||
#[serde(default, skip_serializing)]
|
||||
pub order: Option<u32>,
|
||||
/// The highest level you intend to assess this target at. Omit it to inherit
|
||||
/// the objective's.
|
||||
@@ -1183,6 +1205,33 @@ impl CourseFile {
|
||||
));
|
||||
}
|
||||
}
|
||||
// A manuscript with no journal is a citation nobody can print. The
|
||||
// fields exist; a note that carries them instead is data the reading
|
||||
// list cannot link and the bibliography exporters cannot use.
|
||||
if matches!(
|
||||
reference.kind,
|
||||
ReferenceKind::Article | ReferenceKind::Preprint
|
||||
) && reference.container.is_none()
|
||||
{
|
||||
issues.push(format!(
|
||||
"reference `{key}`: an {} needs a `container` — the journal, preprint \
|
||||
server, or proceedings it appeared in. Run `coursebank migrate references` \
|
||||
if it is sitting in the `note`.",
|
||||
match reference.kind {
|
||||
ReferenceKind::Preprint => "preprint",
|
||||
_ => "article",
|
||||
}
|
||||
));
|
||||
}
|
||||
if let Some(note) = &reference.note {
|
||||
if crate::citation::looks_like_a_citation(note) {
|
||||
issues.push(format!(
|
||||
"reference `{key}`: the note still carries a citation. Volume, pages, \
|
||||
and DOI have their own fields, and a DOI in a note is a link nobody \
|
||||
can follow. `coursebank migrate references` takes it apart."
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(pmid) = &reference.pmid {
|
||||
if !pmid.trim().chars().all(|c| c.is_ascii_digit()) {
|
||||
issues.push(format!(
|
||||
@@ -1646,9 +1695,20 @@ impl CourseFile {
|
||||
.filter(|(_, target)| target.objective == objective)
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
// By ceiling, then by id. A target is a question template rather than a
|
||||
// step in a sequence — an objective's targets are not taught in an
|
||||
// order, and an exam samples from them — so there is no teaching order
|
||||
// to print. What there is is depth, and grouping by it puts the
|
||||
// checklist in the order the study methods apply: recall for a Level 1
|
||||
// target, explanation for Level 2, variations and written solutions
|
||||
// above that. Ties break by id so the list is stable.
|
||||
ids.sort_by_key(|id| {
|
||||
let target = &self.learning_targets[*id];
|
||||
(target.order.unwrap_or(u32::MAX), (*id).clone())
|
||||
(
|
||||
self.effective_level_ceiling(id)
|
||||
.map(|l| l.code())
|
||||
.unwrap_or(0),
|
||||
(*id).clone(),
|
||||
)
|
||||
});
|
||||
ids.into_iter().map(String::as_str).collect()
|
||||
}
|
||||
|
||||
@@ -535,6 +535,27 @@ impl Merge {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -800,6 +821,93 @@ units:
|
||||
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");
|
||||
|
||||
+8
-1
@@ -157,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.
|
||||
|
||||
@@ -64,6 +64,24 @@ impl Layout {
|
||||
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")
|
||||
@@ -117,6 +135,8 @@ impl Layout {
|
||||
pub fn create_all(&self) -> Result<()> {
|
||||
for dir in [
|
||||
self.root.clone(),
|
||||
self.analysis(),
|
||||
self.measurements(),
|
||||
self.lectures(),
|
||||
self.objectives(),
|
||||
self.banks(),
|
||||
|
||||
@@ -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.
|
||||
@@ -458,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,
|
||||
@@ -787,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
|
||||
@@ -1195,6 +1249,7 @@ mod tests {
|
||||
content: true,
|
||||
dropped: Vec::new(),
|
||||
digest: String::new(),
|
||||
superseded_digests: Vec::new(),
|
||||
},
|
||||
items: vec![SealedItem {
|
||||
number: 1,
|
||||
@@ -1343,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,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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user