feat: initial package draft
This commit is contained in:
@@ -0,0 +1,820 @@
|
||||
//! The item: one assessable question, with both the intent it was written with
|
||||
//! and the evidence it has produced.
|
||||
//!
|
||||
//! The organizing idea is that those two things belong in one versioned place.
|
||||
//! [`Design`] is written before an item is ever used and says what you predict:
|
||||
//! how hard, how discriminating, how long, and what the item is meant to reveal.
|
||||
//! [`Calibration`] is written by the tool afterwards and says what happened.
|
||||
//! Keeping them adjacent is what turns a question bank into an instrument you
|
||||
//! can improve, because every administration produces a checkable prediction.
|
||||
//!
|
||||
//! One deliberate departure from a naive design: [`Calibration`] is *cumulative*
|
||||
//! rather than per-administration. Raw per-response data belongs in the Parquet
|
||||
//! tables under `data/`, which are far better at holding it, and an item's YAML
|
||||
//! holds the rolled-up estimate plus a list of which administrations went into
|
||||
//! it. That keeps bank files readable and reviewable in a pull request while
|
||||
//! still letting statistics accumulate across terms.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::date::Date;
|
||||
use crate::hash::fingerprint;
|
||||
use crate::taxonomy::{
|
||||
CognitiveProcess, Discrimination, ErrorType, Flag, Format, Level, ReviewAction, Status,
|
||||
};
|
||||
|
||||
/// One assessable question.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Item {
|
||||
/// Stable id, unique within its bank, conventionally `q-<slug>-NNN`.
|
||||
///
|
||||
/// Ids are never reused and never renumbered: the id is the join key that
|
||||
/// ties an item to every assessment it has appeared on and every response
|
||||
/// row ever recorded for it.
|
||||
pub id: String,
|
||||
|
||||
/// Revision counter, bumped whenever the content changes in a way that
|
||||
/// invalidates pooled statistics.
|
||||
#[serde(default = "one_u32")]
|
||||
pub version: u32,
|
||||
|
||||
/// Workflow state; only [`Status::Approved`] items may be assembled.
|
||||
pub status: Status,
|
||||
|
||||
/// Cognitive demand, 1 through 5.
|
||||
pub level: Level,
|
||||
|
||||
/// The specific process the item elicits. Required for approval, and checked
|
||||
/// against `level`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cognitive_process: Option<CognitiveProcess>,
|
||||
|
||||
/// Response format.
|
||||
#[serde(default = "default_format")]
|
||||
pub format: Format,
|
||||
|
||||
/// A bonus item, scored outside the graded total.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub bonus: bool,
|
||||
|
||||
/// Point value; falls back to the course policy when absent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub points: Option<f64>,
|
||||
|
||||
/// A short human title, used in tables and Canvas question names.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
|
||||
/// Id of a shared stimulus in the course registry, for case-based testlets.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stimulus: Option<String>,
|
||||
|
||||
/// The prompt.
|
||||
pub stem: String,
|
||||
|
||||
/// The answer options in canonical order. Shuffling happens at export time
|
||||
/// per form, never here, so the bank stays diffable.
|
||||
pub options: Vec<Choice>,
|
||||
|
||||
/// Objectives this item measures, as ids into the course registry.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub learning_objectives: Vec<String>,
|
||||
|
||||
/// Where the material was taught.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub sources: Vec<Source>,
|
||||
|
||||
/// Free-form topic tags, for slicing a bank by subject rather than lecture.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub topics: Vec<String>,
|
||||
|
||||
/// Item ids or objective ids a student needs before this is fair.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub prerequisites: Vec<String>,
|
||||
|
||||
/// Figures or data files reproduced with the item.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assets: Vec<Asset>,
|
||||
|
||||
/// What you predicted before using it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub design: Option<Design>,
|
||||
|
||||
/// What the evidence says, accumulated across administrations.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub calibration: Option<Calibration>,
|
||||
|
||||
/// The last review decision recorded for this item.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub review: Option<Review>,
|
||||
|
||||
/// Append-only change log.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub history: Vec<HistoryEntry>,
|
||||
|
||||
/// The author of record.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
|
||||
/// Notes that must never reach a student.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub notes_private: Option<String>,
|
||||
|
||||
/// Set when the item was retired, with the reason.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retired: Option<Retirement>,
|
||||
}
|
||||
|
||||
/// One answer option.
|
||||
///
|
||||
/// The optional fields are what separate a designed distractor from filler. An
|
||||
/// option that names the [`ErrorType`] it targets and the misconception behind it
|
||||
/// is one you can report on: when a third of the cohort picks it, you know what
|
||||
/// they were thinking, and the student report can say so.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Choice {
|
||||
/// Option letter, `A` through `H`.
|
||||
pub id: String,
|
||||
|
||||
/// The option text.
|
||||
pub text: String,
|
||||
|
||||
/// Whether this option is keyed correct.
|
||||
#[serde(default)]
|
||||
pub correct: bool,
|
||||
|
||||
/// Credit awarded, from 0 to 1. Absent means 1.0 when correct, else 0.0.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub credit: Option<f64>,
|
||||
|
||||
/// Instructor-facing rationale for why this option is right or wrong.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub explanation: Option<String>,
|
||||
|
||||
/// The nudge you would give a student reconsidering this option.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub hint: Option<String>,
|
||||
|
||||
/// The specific wrong idea this distractor is built to capture.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub misconception: Option<String>,
|
||||
|
||||
/// The category of that error.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error_type: Option<ErrorType>,
|
||||
|
||||
/// Whether a wrong option is defensible enough to earn partial credit.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub defensible: bool,
|
||||
|
||||
/// The argument for why it is defensible. Required whenever credit is
|
||||
/// awarded to a wrong option, so partial credit is always justified in
|
||||
/// writing rather than by memory.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub defense: Option<String>,
|
||||
|
||||
/// Text released to students after the assessment. This is what a student
|
||||
/// report shows them when they chose this option.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub feedback_student: Option<String>,
|
||||
|
||||
/// Your a priori guess at how often this option is chosen.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selection_rate_expected: Option<f64>,
|
||||
}
|
||||
|
||||
impl Choice {
|
||||
/// The credit this option earns, resolving the default from `correct`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Credit in `[0, 1]`.
|
||||
pub fn credit(&self) -> f64 {
|
||||
self.credit.unwrap_or(if self.correct { 1.0 } else { 0.0 })
|
||||
}
|
||||
|
||||
/// Whether this option awards credit without being keyed correct.
|
||||
pub fn is_partial(&self) -> bool {
|
||||
!self.correct && self.credit() > 0.0
|
||||
}
|
||||
|
||||
/// The best available student-facing explanation of this option.
|
||||
///
|
||||
/// Prefers explicit student feedback, then the misconception, then the
|
||||
/// instructor explanation, so a report degrades gracefully as authoring
|
||||
/// completeness varies.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The text, or `None` when the option carries no rationale at all.
|
||||
pub fn student_text(&self) -> Option<&str> {
|
||||
self.feedback_student
|
||||
.as_deref()
|
||||
.or(self.misconception.as_deref())
|
||||
.or(self.explanation.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the assessed material was taught.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Source {
|
||||
/// Lecture id in the course registry.
|
||||
pub lecture: String,
|
||||
/// Slide numbers, so a student report can point at a page rather than a
|
||||
/// whole lecture.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub slides: Vec<u32>,
|
||||
/// Readings, cited however you cite them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub readings: Vec<String>,
|
||||
/// A timestamp into a recording, in seconds.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub recording_seconds: Option<u32>,
|
||||
}
|
||||
|
||||
/// A figure or data file reproduced with an item.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Asset {
|
||||
/// Path relative to the course root.
|
||||
pub path: String,
|
||||
/// Alt text. Required in practice for accessibility; the linter says so.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub alt: Option<String>,
|
||||
/// A caption printed below the figure.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub caption: Option<String>,
|
||||
}
|
||||
|
||||
/// The a priori design of an item: your predictions, written down.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Design {
|
||||
/// The proportion of the target cohort you expect to answer correctly.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_difficulty: Option<f64>,
|
||||
/// How sharply you expect it to separate students.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_discrimination: Option<Discrimination>,
|
||||
/// How long you expect it to take, in seconds. Summed over a form, this is
|
||||
/// how you check that an exam fits the period.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_time_seconds: Option<f64>,
|
||||
/// What the item is meant to reveal, and why it sits at its level.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rationale: Option<String>,
|
||||
}
|
||||
|
||||
/// Accumulated evidence about an item's behavior.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Calibration {
|
||||
/// The administrations pooled into these numbers.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub administrations: Vec<String>,
|
||||
/// When the calibration was last recomputed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub updated: Option<Date>,
|
||||
/// The content fingerprint these statistics describe. If it differs from the
|
||||
/// item's current fingerprint, the item was edited after calibration and the
|
||||
/// numbers are stale; the linter says so.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fingerprint: Option<String>,
|
||||
/// Total examinees pooled.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub n_examinees: Option<usize>,
|
||||
/// Proportion correct.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub p_value: Option<f64>,
|
||||
/// Corrected item-total point-biserial correlation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub point_biserial: Option<f64>,
|
||||
/// Upper-minus-lower-group discrimination index.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub discrimination_index: Option<f64>,
|
||||
/// Mean response time, when the platform reports it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mean_response_time_seconds: Option<f64>,
|
||||
/// Proportion of responses faster than plausible reading time.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub rapid_guess_rate: Option<f64>,
|
||||
/// Per-option behavior, keyed by option letter.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub option_stats: std::collections::BTreeMap<String, OptionStat>,
|
||||
/// Fitted item response theory parameters.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub irt: Option<IrtParams>,
|
||||
/// Machine-detected problems.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub flags: Vec<Flag>,
|
||||
}
|
||||
|
||||
/// How one option behaved.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OptionStat {
|
||||
/// Proportion of examinees who chose it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selection_rate: Option<f64>,
|
||||
/// Correlation between choosing it and total score. Negative for a working
|
||||
/// distractor; positive on a distractor is a warning.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub point_biserial: Option<f64>,
|
||||
/// Selection rate among the top scoring group.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub upper_group_rate: Option<f64>,
|
||||
/// Selection rate among the bottom scoring group.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub lower_group_rate: Option<f64>,
|
||||
}
|
||||
|
||||
/// Fitted item response theory parameters.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct IrtParams {
|
||||
/// Which model was fitted.
|
||||
pub model: IrtModel,
|
||||
/// Discrimination.
|
||||
pub a: f64,
|
||||
/// Difficulty, on the same scale as ability.
|
||||
pub b: f64,
|
||||
/// Lower asymptote, the guessing parameter.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub c: Option<f64>,
|
||||
/// Standard error of `a`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub se_a: Option<f64>,
|
||||
/// Standard error of `b`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub se_b: Option<f64>,
|
||||
/// Examinees the fit was based on. Small samples give unstable parameters,
|
||||
/// so this travels with them rather than being looked up later.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub n: Option<usize>,
|
||||
/// Whether priors were used, which matters when interpreting `a`.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub bayesian: bool,
|
||||
}
|
||||
|
||||
/// The item response theory model family.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum IrtModel {
|
||||
/// One parameter: difficulty only, discrimination fixed at 1.
|
||||
Rasch,
|
||||
/// Two parameters: discrimination and difficulty.
|
||||
#[serde(rename = "2pl")]
|
||||
TwoPl,
|
||||
/// Three parameters, adding a lower asymptote for guessing.
|
||||
#[serde(rename = "3pl")]
|
||||
ThreePl,
|
||||
}
|
||||
|
||||
impl IrtModel {
|
||||
/// The token used in YAML.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
IrtModel::Rasch => "rasch",
|
||||
IrtModel::TwoPl => "2pl",
|
||||
IrtModel::ThreePl => "3pl",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A recorded review decision.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Review {
|
||||
/// Who reviewed it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reviewed_by: Option<String>,
|
||||
/// When.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reviewed_on: Option<Date>,
|
||||
/// What was decided.
|
||||
pub action: ReviewAction,
|
||||
/// Why.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
/// Why and when an item left service.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Retirement {
|
||||
/// When it was retired.
|
||||
pub on: Date,
|
||||
/// Why.
|
||||
pub reason: String,
|
||||
/// A replacement item id, when one exists.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub replaced_by: Option<String>,
|
||||
}
|
||||
|
||||
/// One entry in an item's change log.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HistoryEntry {
|
||||
/// The version this change produced.
|
||||
pub version: u32,
|
||||
/// When it was made.
|
||||
pub date: Date,
|
||||
/// Who made it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
/// What changed.
|
||||
pub change: String,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Builds a draft item with everything optional left empty.
|
||||
///
|
||||
/// A constructor rather than a `Default` implementation, because there is no
|
||||
/// sensible default id, stem, or option set: an item missing any of those is
|
||||
/// not a lesser item, it is not an item. Requiring them at construction means
|
||||
/// the only way to get a half-built `Item` is deliberately.
|
||||
///
|
||||
/// The result is `Status::Draft` and deliberately will not pass
|
||||
/// [`Item::is_assemblable`] — it still needs learning objectives, sources, and
|
||||
/// a cognitive process before it can be drawn onto an assessment.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `id` - the item id.
|
||||
/// * `level` - the cognitive level.
|
||||
/// * `stem` - the question.
|
||||
/// * `options` - the answer options.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The draft item.
|
||||
pub fn draft(id: &str, level: Level, stem: &str, options: Vec<Choice>) -> Item {
|
||||
Item {
|
||||
id: id.to_string(),
|
||||
version: 1,
|
||||
status: Status::Draft,
|
||||
level,
|
||||
cognitive_process: None,
|
||||
format: if options.iter().filter(|o| o.correct).count() > 1 {
|
||||
Format::MultipleResponse
|
||||
} else {
|
||||
Format::SingleBestAnswer
|
||||
},
|
||||
bonus: false,
|
||||
points: None,
|
||||
title: None,
|
||||
stimulus: None,
|
||||
stem: stem.to_string(),
|
||||
options,
|
||||
learning_objectives: Vec::new(),
|
||||
sources: Vec::new(),
|
||||
topics: Vec::new(),
|
||||
prerequisites: Vec::new(),
|
||||
assets: Vec::new(),
|
||||
design: None,
|
||||
calibration: None,
|
||||
review: None,
|
||||
history: Vec::new(),
|
||||
author: None,
|
||||
notes_private: None,
|
||||
retired: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Indices of the keyed-correct options.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Zero-based indices into `options`.
|
||||
pub fn key_indices(&self) -> Vec<usize> {
|
||||
self.options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, o)| o.correct)
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The keyed-correct option letters, sorted.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Letters such as `["C"]` or `["A", "B"]`.
|
||||
pub fn key_letters(&self) -> Vec<String> {
|
||||
let mut out: Vec<String> = self
|
||||
.options
|
||||
.iter()
|
||||
.filter(|o| o.correct)
|
||||
.map(|o| o.id.clone())
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Looks up an option by letter.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `letter` - the option id, case insensitive.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The option, or `None`.
|
||||
pub fn option(&self, letter: &str) -> Option<&Choice> {
|
||||
self.options
|
||||
.iter()
|
||||
.find(|o| o.id.eq_ignore_ascii_case(letter))
|
||||
}
|
||||
|
||||
/// Whether the item keys more than one option.
|
||||
pub fn is_multi_key(&self) -> bool {
|
||||
self.key_indices().len() > 1
|
||||
}
|
||||
|
||||
/// The display title, falling back to a truncated stem.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A short label suitable for a table or a Canvas question name.
|
||||
pub fn display_title(&self) -> String {
|
||||
if let Some(t) = &self.title {
|
||||
if !t.trim().is_empty() {
|
||||
return t.trim().to_string();
|
||||
}
|
||||
}
|
||||
let flat = self.stem.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if flat.chars().count() <= 60 {
|
||||
flat
|
||||
} else {
|
||||
let head: String = flat.chars().take(57).collect();
|
||||
format!("{head}...")
|
||||
}
|
||||
}
|
||||
|
||||
/// A content fingerprint over everything that affects what a student sees.
|
||||
///
|
||||
/// Metadata deliberately does not contribute: retagging an objective must not
|
||||
/// invalidate pooled statistics, but rewording an option must.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The fingerprint as hex.
|
||||
pub fn fingerprint(&self) -> String {
|
||||
let mut parts: Vec<String> = vec![self.stem.trim().to_string()];
|
||||
// Canonicalize by option letter so reordering the YAML block, which does
|
||||
// not change the item, does not change the fingerprint.
|
||||
let mut opts: Vec<&Choice> = self.options.iter().collect();
|
||||
opts.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
for o in opts {
|
||||
parts.push(format!(
|
||||
"{}|{}|{}",
|
||||
o.id,
|
||||
if o.correct { "1" } else { "0" },
|
||||
o.text.trim()
|
||||
));
|
||||
}
|
||||
if let Some(s) = &self.stimulus {
|
||||
parts.push(format!("stimulus:{s}"));
|
||||
}
|
||||
fingerprint(parts.iter().map(|s| s.as_str()))
|
||||
}
|
||||
|
||||
/// Whether the recorded calibration matches the current content.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `false` when the item was edited after it was calibrated.
|
||||
pub fn calibration_is_current(&self) -> bool {
|
||||
match self
|
||||
.calibration
|
||||
.as_ref()
|
||||
.and_then(|c| c.fingerprint.as_ref())
|
||||
{
|
||||
Some(fp) => *fp == self.fingerprint(),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The point value, resolving against a course default.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `default_points` - the course policy value.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The point value to use.
|
||||
pub fn points(&self, default_points: f64) -> f64 {
|
||||
self.points.unwrap_or(default_points)
|
||||
}
|
||||
|
||||
/// Whether this item may be placed on a graded assessment.
|
||||
pub fn is_assemblable(&self) -> bool {
|
||||
self.status.is_usable() && self.retired.is_none()
|
||||
}
|
||||
|
||||
/// The expected time in seconds, falling back to a level-based estimate.
|
||||
///
|
||||
/// The fallbacks are rough but useful: without them, a form's total time
|
||||
/// estimate silently drops every item that has no `design` block.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Seconds.
|
||||
pub fn expected_seconds(&self) -> f64 {
|
||||
if let Some(t) = self.design.as_ref().and_then(|d| d.expected_time_seconds) {
|
||||
return t;
|
||||
}
|
||||
match self.level {
|
||||
Level::Remember => 35.0,
|
||||
Level::Understand => 65.0,
|
||||
Level::Apply => 95.0,
|
||||
Level::Analyze => 130.0,
|
||||
Level::Create => 165.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends a change-log entry and bumps the version.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `change` - a description of what changed.
|
||||
/// * `author` - who made the change.
|
||||
pub fn record_change(&mut self, change: &str, author: Option<&str>) {
|
||||
self.version += 1;
|
||||
self.history.push(HistoryEntry {
|
||||
version: self.version,
|
||||
date: Date::today(),
|
||||
author: author.map(|a| a.to_string()),
|
||||
change: change.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn one_u32() -> u32 {
|
||||
1
|
||||
}
|
||||
fn default_format() -> Format {
|
||||
Format::SingleBestAnswer
|
||||
}
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn item(src: &str) -> Item {
|
||||
serde_yaml_ng::from_str(src).expect("item parses")
|
||||
}
|
||||
|
||||
const MINIMAL: &str = r#"
|
||||
id: q-demo-001
|
||||
status: draft
|
||||
level: 2
|
||||
stem: Which statement best explains the effect?
|
||||
options:
|
||||
- { id: A, text: Right, correct: true }
|
||||
- { id: B, text: Wrong }
|
||||
- { id: C, text: Also wrong }
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn minimal_item_parses_with_defaults() {
|
||||
let it = item(MINIMAL);
|
||||
assert_eq!(it.version, 1);
|
||||
assert_eq!(it.format, Format::SingleBestAnswer);
|
||||
assert!(!it.bonus);
|
||||
assert_eq!(it.key_letters(), vec!["A"]);
|
||||
assert!(!it.is_multi_key());
|
||||
assert!(!it.is_assemblable(), "drafts are not assemblable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credit_defaults_from_correctness() {
|
||||
let it = item(MINIMAL);
|
||||
assert_eq!(it.option("A").unwrap().credit(), 1.0);
|
||||
assert_eq!(it.option("B").unwrap().credit(), 0.0);
|
||||
assert!(!it.option("B").unwrap().is_partial());
|
||||
|
||||
let with_partial = item(
|
||||
r#"
|
||||
id: q-demo-002
|
||||
status: draft
|
||||
level: 5
|
||||
stem: s
|
||||
options:
|
||||
- { id: A, text: Right, correct: true }
|
||||
- { id: B, text: Defensible, credit: 0.5, defensible: true, defense: because }
|
||||
- { id: C, text: Wrong }
|
||||
"#,
|
||||
);
|
||||
assert!(with_partial.option("B").unwrap().is_partial());
|
||||
assert_eq!(with_partial.option("B").unwrap().credit(), 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_tracks_content_not_metadata() {
|
||||
let base = item(MINIMAL);
|
||||
let mut retagged = base.clone();
|
||||
retagged.topics = vec!["kinetics".into()];
|
||||
retagged.learning_objectives = vec!["lo-a".into()];
|
||||
retagged.author = Some("someone".into());
|
||||
assert_eq!(
|
||||
base.fingerprint(),
|
||||
retagged.fingerprint(),
|
||||
"metadata must not invalidate pooled statistics"
|
||||
);
|
||||
|
||||
let mut reworded = base.clone();
|
||||
reworded.options[1].text = "Wrong, but differently".into();
|
||||
assert_ne!(base.fingerprint(), reworded.fingerprint());
|
||||
|
||||
let mut rekeyed = base.clone();
|
||||
rekeyed.options[0].correct = false;
|
||||
rekeyed.options[1].correct = true;
|
||||
assert_ne!(base.fingerprint(), rekeyed.fingerprint());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_ignores_yaml_option_order() {
|
||||
let a = item(MINIMAL);
|
||||
let b = item(
|
||||
r#"
|
||||
id: q-demo-001
|
||||
status: draft
|
||||
level: 2
|
||||
stem: Which statement best explains the effect?
|
||||
options:
|
||||
- { id: C, text: Also wrong }
|
||||
- { id: A, text: Right, correct: true }
|
||||
- { id: B, text: Wrong }
|
||||
"#,
|
||||
);
|
||||
assert_eq!(a.fingerprint(), b.fingerprint());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_calibration_is_detectable() {
|
||||
let mut it = item(MINIMAL);
|
||||
assert!(it.calibration_is_current(), "no calibration is not stale");
|
||||
|
||||
let fp = it.fingerprint();
|
||||
it.calibration = Some(Calibration {
|
||||
fingerprint: Some(fp),
|
||||
..Calibration::default()
|
||||
});
|
||||
assert!(it.calibration_is_current());
|
||||
|
||||
it.stem = "A different question entirely?".into();
|
||||
assert!(!it.calibration_is_current());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_title_truncates_long_stems() {
|
||||
let mut it = item(MINIMAL);
|
||||
it.title = None;
|
||||
it.stem = "word ".repeat(40);
|
||||
let t = it.display_title();
|
||||
assert!(t.ends_with("..."));
|
||||
assert_eq!(t.chars().count(), 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_item_keys_are_rejected() {
|
||||
let bad = serde_yaml_ng::from_str::<Item>(
|
||||
r#"
|
||||
id: q-demo-001
|
||||
status: draft
|
||||
level: 2
|
||||
stem: s
|
||||
steam: oops
|
||||
options:
|
||||
- { id: A, text: a, correct: true }
|
||||
"#,
|
||||
);
|
||||
assert!(bad.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_change_bumps_version_and_logs() {
|
||||
let mut it = item(MINIMAL);
|
||||
it.record_change("clarified the stem", Some("Alex"));
|
||||
assert_eq!(it.version, 2);
|
||||
assert_eq!(it.history.len(), 1);
|
||||
assert_eq!(it.history[0].version, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn irt_model_tokens_round_trip() {
|
||||
assert_eq!(serde_json::to_string(&IrtModel::TwoPl).unwrap(), "\"2pl\"");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<IrtModel>("\"3pl\"").unwrap(),
|
||||
IrtModel::ThreePl
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user