825 lines
27 KiB
Rust
825 lines
27 KiB
Rust
// SPDX-License-Identifier: Prosperity-3.0.0
|
|
// Copyright Scientific Computing Studio
|
|
// Source: https://git.scient.ing/education/coursebank
|
|
|
|
//! 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
|
|
);
|
|
}
|
|
}
|