Compare commits
1
Commits
5ac1e317c0
...
prelim-use
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6d6ee10b6
|
+105
-41
@@ -28,18 +28,19 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::SCHEMA_VERSION;
|
||||||
use crate::assessment::AssessmentFile;
|
use crate::assessment::AssessmentFile;
|
||||||
use crate::bank::BankFile;
|
use crate::calibration::{CalibrationFile, Measurement, MeasurementFile, MeasurementMeta};
|
||||||
use crate::catalog::Catalog;
|
use crate::catalog::Catalog;
|
||||||
use crate::classical::{self, Analysis, ItemAnalysis, Thresholds};
|
use crate::classical::{self, Analysis, ItemAnalysis, Thresholds};
|
||||||
use crate::date::Date;
|
use crate::date::Date;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::irt::{self, Fit};
|
use crate::irt::{self, Fit};
|
||||||
use crate::item::{Calibration, IrtParams, Item, OptionStat, VariantCalibration};
|
use crate::item::{Calibration, IrtParams, Item, OptionStat, VariantCalibration};
|
||||||
|
use crate::layout::Layout;
|
||||||
use crate::responses::ResponseSet;
|
use crate::responses::ResponseSet;
|
||||||
use crate::store::Store;
|
use crate::store::Store;
|
||||||
use crate::taxonomy::Flag;
|
use crate::taxonomy::Flag;
|
||||||
use crate::yaml;
|
|
||||||
|
|
||||||
/// What calibration would change about one item.
|
/// What calibration would change about one item.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -615,60 +616,123 @@ fn diff_calibration(previous: Option<&Calibration>, next: &Calibration) -> Vec<S
|
|||||||
out
|
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
|
/// One file, `analysis/calibration.yaml`, and never a bank. A bank is reviewed
|
||||||
/// built against a bank that has since changed on disk fails loudly rather than
|
/// for what it asks; its history should be a record of wording decisions, not
|
||||||
/// clobbering the newer version.
|
/// 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
|
/// # Arguments
|
||||||
///
|
///
|
||||||
|
/// * `layout` - the course layout, for where the store lives.
|
||||||
/// * `plan` - the plan to apply.
|
/// * `plan` - the plan to apply.
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// The bank files rewritten.
|
/// The file written.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Returns [`Error::Unresolved`] when an item in the plan is no longer in its bank,
|
/// Returns [`Error::Io`] on a write failure and [`Error::Yaml`] if the existing
|
||||||
/// and [`Error::Io`] on a write failure.
|
/// store does not parse.
|
||||||
pub fn apply(plan: &Plan) -> Result<Vec<PathBuf>> {
|
pub fn apply(layout: &Layout, plan: &Plan) -> Result<PathBuf> {
|
||||||
// Group by file so each is read and written once.
|
let path = layout.calibration_file();
|
||||||
let mut by_file: BTreeMap<&PathBuf, Vec<&Change>> = BTreeMap::new();
|
let mut store = CalibrationFile::load(&path)?;
|
||||||
|
|
||||||
for change in &plan.changes {
|
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();
|
if let Some(parent) = path.parent() {
|
||||||
for (path, changes) in by_file {
|
std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
|
||||||
let mut bank: BankFile = yaml::read(path)?;
|
|
||||||
for change in changes {
|
|
||||||
// The uid is `bank::item`; match on the item part.
|
|
||||||
let item_id = change
|
|
||||||
.uid
|
|
||||||
.split_once("::")
|
|
||||||
.map(|(_, id)| id)
|
|
||||||
.unwrap_or(&change.uid);
|
|
||||||
let target = bank.items.iter_mut().find(|i| i.id == item_id);
|
|
||||||
match target {
|
|
||||||
Some(item) => item.calibration = Some(change.calibration.clone()),
|
|
||||||
None => {
|
|
||||||
return Err(Error::Unresolved {
|
|
||||||
kind: "item",
|
|
||||||
id: change.uid.clone(),
|
|
||||||
context: Some(format!(
|
|
||||||
"{} — the bank changed since the plan was built; re-run calibration",
|
|
||||||
path.display()
|
|
||||||
)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
yaml::write(path, &bank)?;
|
|
||||||
written.push(path.clone());
|
|
||||||
}
|
}
|
||||||
Ok(written)
|
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.
|
/// Builds a plan for a single administration, from an in-memory analysis.
|
||||||
|
|||||||
+127
-10
@@ -37,6 +37,10 @@ pub enum Kind {
|
|||||||
Lecture,
|
Lecture,
|
||||||
/// `objectives/*.yaml`.
|
/// `objectives/*.yaml`.
|
||||||
Objective,
|
Objective,
|
||||||
|
/// `analysis/calibration.yaml`.
|
||||||
|
Calibration,
|
||||||
|
/// `analysis/administrations/*.yaml`.
|
||||||
|
Measurements,
|
||||||
/// `banks/*.yaml`.
|
/// `banks/*.yaml`.
|
||||||
Bank,
|
Bank,
|
||||||
/// `assessments/*.yaml`.
|
/// `assessments/*.yaml`.
|
||||||
@@ -45,13 +49,15 @@ pub enum Kind {
|
|||||||
|
|
||||||
impl Kind {
|
impl Kind {
|
||||||
/// Every kind.
|
/// Every kind.
|
||||||
pub const ALL: [Kind; 6] = [
|
pub const ALL: [Kind; 8] = [
|
||||||
Kind::Course,
|
Kind::Course,
|
||||||
Kind::References,
|
Kind::References,
|
||||||
Kind::Lecture,
|
Kind::Lecture,
|
||||||
Kind::Objective,
|
Kind::Objective,
|
||||||
Kind::Bank,
|
Kind::Bank,
|
||||||
Kind::Assessment,
|
Kind::Assessment,
|
||||||
|
Kind::Calibration,
|
||||||
|
Kind::Measurements,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The file name a schema is written to.
|
/// The file name a schema is written to.
|
||||||
@@ -61,6 +67,8 @@ impl Kind {
|
|||||||
Kind::References => "references.schema.json",
|
Kind::References => "references.schema.json",
|
||||||
Kind::Lecture => "lecture.schema.json",
|
Kind::Lecture => "lecture.schema.json",
|
||||||
Kind::Objective => "objective.schema.json",
|
Kind::Objective => "objective.schema.json",
|
||||||
|
Kind::Calibration => "calibration.schema.json",
|
||||||
|
Kind::Measurements => "measurements.schema.json",
|
||||||
Kind::Bank => "bank.schema.json",
|
Kind::Bank => "bank.schema.json",
|
||||||
Kind::Assessment => "assessment.schema.json",
|
Kind::Assessment => "assessment.schema.json",
|
||||||
}
|
}
|
||||||
@@ -99,6 +107,8 @@ pub fn schema(kind: Kind) -> Value {
|
|||||||
Kind::References => references_schema(),
|
Kind::References => references_schema(),
|
||||||
Kind::Lecture => lecture_fragment_schema(),
|
Kind::Lecture => lecture_fragment_schema(),
|
||||||
Kind::Objective => objective_fragment_schema(),
|
Kind::Objective => objective_fragment_schema(),
|
||||||
|
Kind::Calibration => calibration_file_schema(),
|
||||||
|
Kind::Measurements => measurements_file_schema(),
|
||||||
Kind::Bank => bank_schema(),
|
Kind::Bank => bank_schema(),
|
||||||
Kind::Assessment => assessment_schema(),
|
Kind::Assessment => assessment_schema(),
|
||||||
}
|
}
|
||||||
@@ -381,9 +391,12 @@ fn target_schema() -> Value {
|
|||||||
"order": {
|
"order": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"description": "Position among the other targets of the same objective, low \
|
"description": "Ignored since 2.0. An objective's targets are a set of question \
|
||||||
first. Ordered within its objective rather than across the \
|
templates, not steps in a sequence — they are not taught in \
|
||||||
course, so inserting one renumbers nothing outside its group."
|
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(),
|
"level_ceiling": level(),
|
||||||
"prerequisites": string_array(
|
"prerequisites": string_array(
|
||||||
@@ -412,8 +425,10 @@ fn objective_schema() -> Value {
|
|||||||
"order": {
|
"order": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"description": "Position in teaching order, low first. Without it objectives \
|
"description": "Position in teaching order, low first. Derived since 2.0 from \
|
||||||
sort by id, which puts one before its own prerequisite."
|
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(),
|
"level_ceiling": level(),
|
||||||
"prerequisites": string_array(
|
"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.
|
/// The schema for a retirement record.
|
||||||
fn retirement_schema() -> Value {
|
fn retirement_schema() -> Value {
|
||||||
json!({
|
json!({
|
||||||
@@ -1080,9 +1195,12 @@ fn item_identity_properties() -> Value {
|
|||||||
json!({
|
json!({
|
||||||
"id": {
|
"id": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^q-[a-z0-9]+(-[a-z0-9]+)*-[0-9]{3}$",
|
"pattern": "^q-[a-z0-9]+(-[a-z0-9]+)*$",
|
||||||
"description": "Item id, e.g. q-glycolysis-014. Stable forever: assessment records \
|
"description": "Item id, e.g. q-glycolysis-rate-limiting-step. Stable forever: \
|
||||||
and stored responses refer to it."
|
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": {
|
"version": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -1149,7 +1267,6 @@ fn item_content_properties() -> Value {
|
|||||||
"prerequisites": string_array("Objective ids a student needs before this item."),
|
"prerequisites": string_array("Objective ids a student needs before this item."),
|
||||||
"assets": { "type": "array", "items": asset_schema() },
|
"assets": { "type": "array", "items": asset_schema() },
|
||||||
"design": design_schema(),
|
"design": design_schema(),
|
||||||
"calibration": calibration_schema(),
|
|
||||||
"review": review_schema(),
|
"review": review_schema(),
|
||||||
"history": {
|
"history": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
|||||||
+39
@@ -137,6 +137,37 @@ pub(crate) enum MigrateCommand {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
dry_run: bool,
|
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.
|
/// Fill in the stored `variant` column from the assessment records.
|
||||||
///
|
///
|
||||||
/// Nothing in the tool needs it — a variant is derived from the placement
|
/// 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.
|
/// Pool every stored administration of this assessment.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pooled: bool,
|
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.
|
/// Fit an IRT model.
|
||||||
Irt {
|
Irt {
|
||||||
|
|||||||
@@ -294,12 +294,46 @@ pub(crate) fn analyze(cli: &Cli, sub: &AnalyzeCommand) -> Result<Outcome> {
|
|||||||
let store = Store::open(catalog.layout.data())?;
|
let store = Store::open(catalog.layout.data())?;
|
||||||
|
|
||||||
match sub {
|
match sub {
|
||||||
AnalyzeCommand::Items { id, pooled } => {
|
AnalyzeCommand::Items {
|
||||||
|
id,
|
||||||
|
pooled,
|
||||||
|
record: write_record,
|
||||||
|
} => {
|
||||||
let record = load_record(&catalog, id)?;
|
let record = load_record(&catalog, id)?;
|
||||||
let set = responses_for(&store, &catalog, &record, *pooled)?;
|
let set = responses_for(&store, &catalog, &record, *pooled)?;
|
||||||
let analysis =
|
let analysis =
|
||||||
classical::analyze(&set, &Thresholds::default(), Some(&record), Some(&catalog));
|
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 {
|
for w in &analysis.warnings {
|
||||||
println!("! {w}\n");
|
println!("! {w}\n");
|
||||||
}
|
}
|
||||||
@@ -463,17 +497,20 @@ pub(crate) fn calibrate(cli: &Cli, args: &CalibrateArgs) -> Result<Outcome> {
|
|||||||
}
|
}
|
||||||
if !args.apply {
|
if !args.apply {
|
||||||
println!(
|
println!(
|
||||||
"Nothing written. Re-run with --apply to write these {} change(s) into the bank \
|
"Nothing written. Re-run with --apply to write these {} change(s) into \
|
||||||
files, then review the git diff.",
|
analysis/calibration.yaml, then review the git diff.",
|
||||||
plan.changes.len()
|
plan.changes.len()
|
||||||
);
|
);
|
||||||
return Ok(Outcome::Ok);
|
return Ok(Outcome::Ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
for path in calibrate::apply(&plan)? {
|
let path = calibrate::apply(&catalog.layout, &plan)?;
|
||||||
println!("updated {}", path.display());
|
println!("updated {}", path.display());
|
||||||
}
|
println!(
|
||||||
println!("\nReview the diff before committing: git diff banks/");
|
"\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)
|
Ok(Outcome::Ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+106
-1
@@ -35,10 +35,15 @@ use crate::helpers::{load, truncate};
|
|||||||
|
|
||||||
/// The `.gitignore` written by `init`.
|
/// The `.gitignore` written by `init`.
|
||||||
pub(crate) const GITIGNORE: &str = "\
|
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/
|
build/
|
||||||
reports/
|
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.
|
# Typst and PDF artifacts.
|
||||||
*.pdf
|
*.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::Options { dry_run } => migrate_options(cli, *dry_run),
|
||||||
MigrateCommand::Stems { dry_run } => migrate_stems(cli, *dry_run),
|
MigrateCommand::Stems { dry_run } => migrate_stems(cli, *dry_run),
|
||||||
MigrateCommand::Variants { dry_run } => migrate_variants(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.
|
/// Fills in the stored variant column.
|
||||||
fn migrate_variants(cli: &Cli, dry_run: bool) -> Result<Outcome> {
|
fn migrate_variants(cli: &Cli, dry_run: bool) -> Result<Outcome> {
|
||||||
let touched = migrate::store_variants(&cli.course, !dry_run)?;
|
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())?;
|
let seals = coursebank::seal::SealFile::load_all(&catalog.layout.seals())?;
|
||||||
all.extend(catalog.validate_seals(&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 all.is_empty() {
|
||||||
if !cli.quiet {
|
if !cli.quiet {
|
||||||
println!(
|
println!(
|
||||||
|
|||||||
+4
-2
@@ -106,9 +106,11 @@ pub mod migrate;
|
|||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
||||||
pub use util::{date, hash, markup, rng, yaml, zipfile};
|
pub use util::{citation, date, hash, markup, rng, yaml, zipfile};
|
||||||
|
|
||||||
pub use model::{assessment, bank, catalog, course, history, item, layout, seal, taxonomy};
|
pub use model::{
|
||||||
|
assessment, bank, calibration, catalog, course, history, item, layout, seal, taxonomy,
|
||||||
|
};
|
||||||
|
|
||||||
pub use course::fragment;
|
pub use 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 |
|
//! | [`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 |
|
//! | [`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 |
|
//! | [`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
|
//! # What the option migration does not touch
|
||||||
//!
|
//!
|
||||||
@@ -96,7 +99,7 @@ pub struct Plan {
|
|||||||
|
|
||||||
impl Plan {
|
impl Plan {
|
||||||
/// The number of lines each planned file holds.
|
/// The number of lines each planned file holds.
|
||||||
pub fn lines(&self) -> Vec<(PathBuf, usize)> {
|
pub fn lines(&self) -> Report {
|
||||||
self.files
|
self.files
|
||||||
.iter()
|
.iter()
|
||||||
.map(|f| (f.path.clone(), f.text.lines().count()))
|
.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
|
/// Propagates read and write failures, including
|
||||||
/// [`Error::FeatureDisabled`] when the store is Parquet and the feature is off.
|
/// [`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 layout = Layout::new(root);
|
||||||
let store = Store::open(layout.data())?;
|
let store = Store::open(layout.data())?;
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
@@ -629,6 +632,15 @@ pub fn store_ids(root: &Path, write: bool) -> Result<Vec<(PathBuf, usize)>> {
|
|||||||
Ok(out)
|
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.
|
/// 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>>;
|
pub type OptionMap = BTreeMap<String, BTreeMap<String, String>>;
|
||||||
|
|
||||||
@@ -844,7 +856,7 @@ pub fn options_plan(root: &Path) -> Result<(OptionMap, Vec<String>)> {
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Propagates read and write failures.
|
/// 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 layout = Layout::new(root);
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
|
|
||||||
@@ -1132,7 +1144,7 @@ fn key_at_word(line: &str, key: &str) -> Option<usize> {
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Propagates read and write failures.
|
/// 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 layout = Layout::new(root);
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
|
|
||||||
@@ -1221,7 +1233,7 @@ fn is_key(trimmed: &str, key: &str) -> bool {
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// Propagates catalog, record, and store failures.
|
/// 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 layout = Layout::new(root);
|
||||||
let catalog = crate::catalog::Catalog::load(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)
|
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.
|
/// One block of YAML: a key, the lines under it, and the comments above it.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
struct Block {
|
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]
|
#[test]
|
||||||
fn lecture_slugs_match_the_house_naming() {
|
fn lecture_slugs_match_the_house_naming() {
|
||||||
assert_eq!(lecture_slug("L1.2"), "l-1-2");
|
assert_eq!(lecture_slug("L1.2"), "l-1-2");
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
|
|
||||||
pub mod assessment;
|
pub mod assessment;
|
||||||
pub mod bank;
|
pub mod bank;
|
||||||
|
pub mod calibration;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod course;
|
pub mod course;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
|
|||||||
+6
-32
@@ -530,38 +530,12 @@ fn validate_item(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- calibration plausibility ------
|
// --- calibration plausibility ------
|
||||||
if let Some(c) = &it.calibration {
|
if it.calibration.is_some() {
|
||||||
if let Some(p) = c.p_value {
|
issues.push(
|
||||||
if !(0.0..=1.0).contains(&p) {
|
"has a `calibration:` block, but statistics live in analysis/calibration.yaml since \
|
||||||
issues.push(format!(
|
2.0. A bank's diff should be a change of intent, not the output of a grading run."
|
||||||
"calibration.p_value must be between 0 and 1, got {p}"
|
.to_string(),
|
||||||
));
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
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}"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- retirement -----
|
// --- 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>,
|
pub entries: Vec<Entry>,
|
||||||
/// Bank metadata by bank id.
|
/// Bank metadata by bank id.
|
||||||
pub banks: BTreeMap<String, crate::bank::BankMeta>,
|
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`.
|
/// Map from global id to index into `entries`.
|
||||||
index: BTreeMap<String, usize>,
|
index: BTreeMap<String, usize>,
|
||||||
}
|
}
|
||||||
@@ -104,7 +110,10 @@ impl Catalog {
|
|||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
banks: BTreeMap::new(),
|
banks: BTreeMap::new(),
|
||||||
index: 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 problems = Vec::new();
|
||||||
let mut files = yaml::list_yaml(&catalog.layout.banks())?;
|
let mut files = yaml::list_yaml(&catalog.layout.banks())?;
|
||||||
@@ -145,6 +154,15 @@ impl Catalog {
|
|||||||
if !problems.is_empty() {
|
if !problems.is_empty() {
|
||||||
return Err(Error::Invalid(problems));
|
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)
|
Ok(catalog)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+69
-9
@@ -800,15 +800,25 @@ pub struct Objective {
|
|||||||
/// The lectures that develop it.
|
/// The lectures that develop it.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub lectures: Vec<String>,
|
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
|
/// 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
|
/// prerequisite of the first. Something has to supply the order.
|
||||||
/// you teach them, a lecture page above all, needs this. Objectives without
|
///
|
||||||
/// it sort last, by id.
|
/// 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")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub order: Option<u32>,
|
pub order: Option<u32>,
|
||||||
|
|
||||||
/// The highest level you intend to assess this objective at. Assembling an
|
/// 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
|
/// item above the ceiling is a warning: either the item overreaches or the
|
||||||
/// ceiling needs raising.
|
/// ceiling needs raising.
|
||||||
@@ -865,8 +875,20 @@ pub struct Target {
|
|||||||
///
|
///
|
||||||
/// Ordered within its objective rather than across the course, so two
|
/// Ordered within its objective rather than across the course, so two
|
||||||
/// targets under different objectives never compete for a position and
|
/// targets under different objectives never compete for a position and
|
||||||
/// inserting one renumbers nothing outside its own group.
|
/// Retained only so a pre-2.0 course still loads. Ignored.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
///
|
||||||
|
/// 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>,
|
pub order: Option<u32>,
|
||||||
/// The highest level you intend to assess this target at. Omit it to inherit
|
/// The highest level you intend to assess this target at. Omit it to inherit
|
||||||
/// the objective's.
|
/// 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 let Some(pmid) = &reference.pmid {
|
||||||
if !pmid.trim().chars().all(|c| c.is_ascii_digit()) {
|
if !pmid.trim().chars().all(|c| c.is_ascii_digit()) {
|
||||||
issues.push(format!(
|
issues.push(format!(
|
||||||
@@ -1646,9 +1695,20 @@ impl CourseFile {
|
|||||||
.filter(|(_, target)| target.objective == objective)
|
.filter(|(_, target)| target.objective == objective)
|
||||||
.map(|(id, _)| id)
|
.map(|(id, _)| id)
|
||||||
.collect();
|
.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| {
|
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()
|
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
|
// A target with no lecture of its own is taught wherever its objective
|
||||||
// is. Collected first: the read of `objectives` and the write to
|
// is. Collected first: the read of `objectives` and the write to
|
||||||
// `targets` cannot overlap in one pass.
|
// `targets` cannot overlap in one pass.
|
||||||
@@ -800,6 +821,93 @@ units:
|
|||||||
assert!(course.origin("nothing-like-this").is_none());
|
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]
|
#[test]
|
||||||
fn teaching_the_same_objective_from_two_lectures_unions() {
|
fn teaching_the_same_objective_from_two_lectures_unions() {
|
||||||
let root = tmp("union");
|
let root = tmp("union");
|
||||||
|
|||||||
+8
-1
@@ -157,7 +157,14 @@ pub struct Item {
|
|||||||
pub design: Option<Design>,
|
pub design: Option<Design>,
|
||||||
|
|
||||||
/// What the evidence says, accumulated across administrations.
|
/// 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>,
|
pub calibration: Option<Calibration>,
|
||||||
|
|
||||||
/// The last review decision recorded for this item.
|
/// The last review decision recorded for this item.
|
||||||
|
|||||||
@@ -64,6 +64,24 @@ impl Layout {
|
|||||||
self.root.join("banks")
|
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.
|
/// Directory holding assessment records.
|
||||||
pub fn assessments(&self) -> PathBuf {
|
pub fn assessments(&self) -> PathBuf {
|
||||||
self.root.join("assessments")
|
self.root.join("assessments")
|
||||||
@@ -117,6 +135,8 @@ impl Layout {
|
|||||||
pub fn create_all(&self) -> Result<()> {
|
pub fn create_all(&self) -> Result<()> {
|
||||||
for dir in [
|
for dir in [
|
||||||
self.root.clone(),
|
self.root.clone(),
|
||||||
|
self.analysis(),
|
||||||
|
self.measurements(),
|
||||||
self.lectures(),
|
self.lectures(),
|
||||||
self.objectives(),
|
self.objectives(),
|
||||||
self.banks(),
|
self.banks(),
|
||||||
|
|||||||
@@ -124,6 +124,26 @@ pub struct SealMeta {
|
|||||||
/// from the file's own contents; [`SealFile::verify`] rebuilds a seal from the
|
/// from the file's own contents; [`SealFile::verify`] rebuilds a seal from the
|
||||||
/// live course and compares this against it.
|
/// live course and compares this against it.
|
||||||
pub digest: String,
|
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.
|
/// What wrote a seal.
|
||||||
@@ -458,6 +478,7 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Resu
|
|||||||
content: opts.content,
|
content: opts.content,
|
||||||
dropped,
|
dropped,
|
||||||
digest: String::new(),
|
digest: String::new(),
|
||||||
|
superseded_digests: Vec::new(),
|
||||||
},
|
},
|
||||||
items,
|
items,
|
||||||
forms: sealed_forms,
|
forms: sealed_forms,
|
||||||
@@ -787,6 +808,39 @@ impl SealFile {
|
|||||||
buf
|
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.
|
/// Recomputes this file's digest from its own contents.
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
@@ -1195,6 +1249,7 @@ mod tests {
|
|||||||
content: true,
|
content: true,
|
||||||
dropped: Vec::new(),
|
dropped: Vec::new(),
|
||||||
digest: String::new(),
|
digest: String::new(),
|
||||||
|
superseded_digests: Vec::new(),
|
||||||
},
|
},
|
||||||
items: vec![SealedItem {
|
items: vec![SealedItem {
|
||||||
number: 1,
|
number: 1,
|
||||||
@@ -1343,6 +1398,31 @@ mod tests {
|
|||||||
assert_eq!(short("sha256:0123456789abcdef"), "sha256:01234567");
|
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]
|
#[test]
|
||||||
fn round_trips_through_yaml() {
|
fn round_trips_through_yaml() {
|
||||||
let file = sample();
|
let file = sample();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
//! replaces a dependency that would otherwise need to keep working for as long as
|
//! replaces a dependency that would otherwise need to keep working for as long as
|
||||||
//! a course repository needs to stay readable.
|
//! a course repository needs to stay readable.
|
||||||
|
|
||||||
|
pub mod citation;
|
||||||
pub mod date;
|
pub mod date;
|
||||||
pub mod hash;
|
pub mod hash;
|
||||||
pub mod markup;
|
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