feat: initial package draft

This commit is contained in:
2026-08-05 23:18:16 -04:00
parent 66291ba545
commit 09c3222f8d
36 changed files with 21604 additions and 0 deletions
+639
View File
@@ -0,0 +1,639 @@
//! The pedagogical vocabulary: levels, cognitive processes, error types, and
//! workflow states.
//!
//! These are enums rather than strings on purpose. A typo in
//! `cognitive_process` should fail to parse, not silently create a new category
//! that then splits your coverage report in two. Just as importantly, the
//! relation between a level and the processes that belong to it is encoded here
//! in one place, so "level 3, cognitive_process: recall" is a validation error
//! rather than a label that quietly contradicts itself.
use std::fmt;
use serde::{Deserialize, Serialize};
/// Cognitive demand, following the revised Bloom taxonomy.
///
/// Serialized as the integers 1 through 5 so YAML reads `level: 3`. The derived
/// [`Ord`] follows declaration order, which is ascending demand.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "u8", into = "u8")]
pub enum Level {
/// Retrieve a fact or definition.
Remember,
/// Construct meaning; explain, compare, classify.
Understand,
/// Carry out a procedure in a given situation.
Apply,
/// Break material apart and relate the pieces.
Analyze,
/// Judge against criteria, or assemble something new.
Create,
}
impl Level {
/// Every level in ascending order, for iteration in reports.
pub const ALL: [Level; 5] = [
Level::Remember,
Level::Understand,
Level::Apply,
Level::Analyze,
Level::Create,
];
/// The numeric code used in YAML and on printed badges.
pub fn code(self) -> u8 {
match self {
Level::Remember => 1,
Level::Understand => 2,
Level::Apply => 3,
Level::Analyze => 4,
Level::Create => 5,
}
}
/// The level for a numeric code, if it is one.
///
/// Flat storage writes `0` for an unknown level rather than an empty cell,
/// because a numeric column with holes in it is awkward in every columnar
/// format. This turns that convention back into an `Option`.
///
/// # Arguments
///
/// * `code` - the numeric code, where anything outside 1..=5 means unknown.
///
/// # Returns
///
/// The level, or `None`.
pub fn from_code(code: u8) -> Option<Level> {
match code {
1 => Some(Level::Remember),
2 => Some(Level::Understand),
3 => Some(Level::Apply),
4 => Some(Level::Analyze),
5 => Some(Level::Create),
_ => None,
}
}
/// The category name, e.g. `"Apply"`.
pub fn name(self) -> &'static str {
match self {
Level::Remember => "Remember",
Level::Understand => "Understand",
Level::Apply => "Apply",
Level::Analyze => "Analyze",
Level::Create => "Evaluate/Create",
}
}
/// A one-line description suitable for a student-facing report.
pub fn blurb(self) -> &'static str {
match self {
Level::Remember => "recalling terms, facts, and definitions",
Level::Understand => "explaining ideas in your own words",
Level::Apply => "using a procedure in a new situation",
Level::Analyze => "taking a situation apart and relating the pieces",
Level::Create => "judging alternatives or building something new",
}
}
/// The cognitive processes that belong to this level.
pub fn processes(self) -> &'static [CognitiveProcess] {
use CognitiveProcess as P;
match self {
Level::Remember => &[P::Recognize, P::Recall],
Level::Understand => &[
P::Interpret,
P::Exemplify,
P::Classify,
P::Summarize,
P::Infer,
P::Compare,
P::Explain,
],
Level::Apply => &[P::Execute, P::Implement],
Level::Analyze => &[P::Differentiate, P::Organize, P::Attribute],
Level::Create => &[P::Check, P::Critique, P::Generate, P::Plan, P::Produce],
}
}
/// Whether a process is consistent with this level.
///
/// # Arguments
///
/// * `process` - the process to check.
///
/// # Returns
///
/// `true` when the pairing is coherent.
pub fn allows(self, process: CognitiveProcess) -> bool {
self.processes().contains(&process)
}
}
impl TryFrom<u8> for Level {
type Error = String;
fn try_from(v: u8) -> Result<Level, String> {
match v {
1 => Ok(Level::Remember),
2 => Ok(Level::Understand),
3 => Ok(Level::Apply),
4 => Ok(Level::Analyze),
5 => Ok(Level::Create),
other => Err(format!("level must be 1 through 5, got {other}")),
}
}
}
impl From<Level> for u8 {
fn from(l: Level) -> u8 {
l.code()
}
}
impl fmt::Display for Level {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "L{}", self.code())
}
}
/// The specific cognitive process an item is designed to elicit.
///
/// Naming the process, not just the level, is what makes the level claim
/// checkable: it forces you to say which of the several things "Understand"
/// could mean you actually wrote.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CognitiveProcess {
/// Identify a previously encountered item.
Recognize,
/// Retrieve from long-term memory unaided.
Recall,
/// Restate in another representation.
Interpret,
/// Give an instance of a category.
Exemplify,
/// Assign an instance to a category.
Classify,
/// Abstract a general theme.
Summarize,
/// Draw a logical conclusion from given information.
Infer,
/// Detect correspondences between two things.
Compare,
/// Construct a cause-and-effect account.
Explain,
/// Apply a procedure to a familiar task.
Execute,
/// Apply a procedure to an unfamiliar task.
Implement,
/// Distinguish relevant from irrelevant parts.
Differentiate,
/// Determine how elements fit a structure.
Organize,
/// Determine a point of view or intent.
Attribute,
/// Test for internal consistency.
Check,
/// Judge against external criteria.
Critique,
/// Propose alternative hypotheses.
Generate,
/// Devise a procedure.
Plan,
/// Construct a product.
Produce,
}
impl CognitiveProcess {
/// The level this process belongs to.
pub fn level(self) -> Level {
for level in Level::ALL {
if level.allows(self) {
return level;
}
}
// Unreachable: every variant appears in exactly one level's list.
Level::Remember
}
/// Every process, in level order.
pub const ALL: [CognitiveProcess; 19] = [
CognitiveProcess::Recognize,
CognitiveProcess::Recall,
CognitiveProcess::Interpret,
CognitiveProcess::Exemplify,
CognitiveProcess::Classify,
CognitiveProcess::Summarize,
CognitiveProcess::Infer,
CognitiveProcess::Compare,
CognitiveProcess::Explain,
CognitiveProcess::Execute,
CognitiveProcess::Implement,
CognitiveProcess::Differentiate,
CognitiveProcess::Organize,
CognitiveProcess::Attribute,
CognitiveProcess::Check,
CognitiveProcess::Critique,
CognitiveProcess::Generate,
CognitiveProcess::Plan,
CognitiveProcess::Produce,
];
/// The snake_case token used in YAML.
pub fn as_str(self) -> &'static str {
use CognitiveProcess as P;
match self {
P::Recognize => "recognize",
P::Recall => "recall",
P::Interpret => "interpret",
P::Exemplify => "exemplify",
P::Classify => "classify",
P::Summarize => "summarize",
P::Infer => "infer",
P::Compare => "compare",
P::Explain => "explain",
P::Execute => "execute",
P::Implement => "implement",
P::Differentiate => "differentiate",
P::Organize => "organize",
P::Attribute => "attribute",
P::Check => "check",
P::Critique => "critique",
P::Generate => "generate",
P::Plan => "plan",
P::Produce => "produce",
}
}
}
impl fmt::Display for CognitiveProcess {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// The category of mistake a distractor is built to capture.
///
/// This does double duty. It disciplines authoring, because a distractor you
/// cannot name an error for is probably filler. And it makes item analysis
/// legible afterwards: a high selection rate on a `DroppedStep` option tells you
/// where in a procedure students slip, which a bare letter never would.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorType {
/// Confused one remembered fact for a neighboring one.
RecallConfusion,
/// Swapped two terms that sound or look alike.
TerminologySwap,
/// Holds a specific, nameable wrong model.
Misconception,
/// Knows part of the idea but not all of it.
IncompleteUnderstanding,
/// Applied a valid rule outside its scope.
Overgeneralization,
/// True but irrelevant to the question asked.
PlausibleIrrelevant,
/// Omitted a step in a procedure.
DroppedStep,
/// Inverted the direction of a relationship.
ReversedRelationship,
/// Used a procedure that does not apply here.
WrongProcedure,
/// Right method, wrong sign or order of magnitude.
SignOrMagnitudeError,
/// Ignored a condition that interacts with the answer.
IgnoresInteractingCondition,
/// Correct as far as it goes, but not the best answer.
CorrectButIncomplete,
/// Took a heuristic shortcut that usually works.
CommonShortcut,
}
impl ErrorType {
/// Every error type.
pub const ALL: [ErrorType; 13] = [
ErrorType::RecallConfusion,
ErrorType::TerminologySwap,
ErrorType::Misconception,
ErrorType::IncompleteUnderstanding,
ErrorType::Overgeneralization,
ErrorType::PlausibleIrrelevant,
ErrorType::DroppedStep,
ErrorType::ReversedRelationship,
ErrorType::WrongProcedure,
ErrorType::SignOrMagnitudeError,
ErrorType::IgnoresInteractingCondition,
ErrorType::CorrectButIncomplete,
ErrorType::CommonShortcut,
];
/// The snake_case token used in YAML.
pub fn as_str(self) -> &'static str {
use ErrorType as E;
match self {
E::RecallConfusion => "recall_confusion",
E::TerminologySwap => "terminology_swap",
E::Misconception => "misconception",
E::IncompleteUnderstanding => "incomplete_understanding",
E::Overgeneralization => "overgeneralization",
E::PlausibleIrrelevant => "plausible_irrelevant",
E::DroppedStep => "dropped_step",
E::ReversedRelationship => "reversed_relationship",
E::WrongProcedure => "wrong_procedure",
E::SignOrMagnitudeError => "sign_or_magnitude_error",
E::IgnoresInteractingCondition => "ignores_interacting_condition",
E::CorrectButIncomplete => "correct_but_incomplete",
E::CommonShortcut => "common_shortcut",
}
}
/// A short instructor-facing gloss.
pub fn gloss(self) -> &'static str {
use ErrorType as E;
match self {
E::RecallConfusion => "confused with a neighboring fact",
E::TerminologySwap => "swapped similar terms",
E::Misconception => "specific wrong model",
E::IncompleteUnderstanding => "partial grasp of the idea",
E::Overgeneralization => "applied a rule outside its scope",
E::PlausibleIrrelevant => "true but not what was asked",
E::DroppedStep => "skipped a step",
E::ReversedRelationship => "reversed the direction",
E::WrongProcedure => "used the wrong procedure",
E::SignOrMagnitudeError => "sign or magnitude slip",
E::IgnoresInteractingCondition => "ignored an interacting condition",
E::CorrectButIncomplete => "correct but not best",
E::CommonShortcut => "took a familiar shortcut",
}
}
}
/// Where an item sits in the authoring workflow.
///
/// The state gates what the validator requires. A draft may be a bare idea; an
/// approved item must be fully sourced and designed, because approval is what
/// permits it onto a graded assessment. Retired items are kept forever so the
/// bank is an append-only record of what you have asked students.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
/// Captured but not yet worked out.
Draft,
/// Written and awaiting review.
InReview,
/// Reviewed and sent back for changes.
NeedsRevision,
/// Cleared for use on a graded assessment.
Approved,
/// Withdrawn from use but retained for the record.
Retired,
}
impl Status {
/// Whether an item in this state may appear on a graded assessment.
pub fn is_usable(self) -> bool {
matches!(self, Status::Approved)
}
/// The snake_case token used in YAML.
pub fn as_str(self) -> &'static str {
match self {
Status::Draft => "draft",
Status::InReview => "in_review",
Status::NeedsRevision => "needs_revision",
Status::Approved => "approved",
Status::Retired => "retired",
}
}
}
impl fmt::Display for Status {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// The response format of an item.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Format {
/// Exactly one keyed option.
SingleBestAnswer,
/// One or more keyed options; the student must find every one.
MultipleResponse,
/// Two options, True and False.
TrueFalse,
}
impl Format {
/// The QTI question type Canvas expects for this format.
pub fn qti_type(self) -> &'static str {
match self {
Format::SingleBestAnswer => "multiple_choice_question",
Format::MultipleResponse => "multiple_answers_question",
Format::TrueFalse => "true_false_question",
}
}
}
/// How strongly an item is expected to separate strong from weak students.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Discrimination {
/// Most prepared students get it; it anchors rather than separates.
Low,
/// Separates somewhat.
Moderate,
/// Expected to separate sharply.
High,
}
impl Discrimination {
/// The point-biserial band this expectation implies, as `(low, high)`.
///
/// Used to check an a priori expectation against the observed statistic.
pub fn expected_band(self) -> (f64, f64) {
match self {
Discrimination::Low => (-1.0, 0.20),
Discrimination::Moderate => (0.15, 0.40),
Discrimination::High => (0.30, 1.0),
}
}
}
/// What was done about an item after reviewing its statistics.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewAction {
/// Behaved as intended; leave it alone.
Keep,
/// Rewrite before reusing.
Revise,
/// Credit a defensible distractor for this administration.
AwardPartialCredit,
/// The key was wrong; fix it and rescore.
CorrectKey,
/// Withdraw from use.
Retire,
/// Keep but watch on the next administration.
Monitor,
}
/// A machine-detected problem with an item's observed behavior.
///
/// These are written by analysis, not by hand, and they are the queue you work
/// through when deciding what to revise.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Flag {
/// Weaker students outperformed stronger ones. Almost always a keying error
/// or a genuinely ambiguous stem.
NegativeDiscrimination,
/// Barely separates students.
LowDiscrimination,
/// A distractor correlates with total score better than the key does.
DistractorOutperformsKey,
/// Strong students chose one particular distractor at a high rate, which is
/// the signature of a second defensible reading.
KeyUnderperforms,
/// Nearly everyone answered correctly; carries little information.
TooEasy,
/// Nearly everyone answered incorrectly and it did not discriminate.
TooHard,
/// Many responses were faster than plausible reading time.
HighRapidGuess,
/// A distractor almost nobody chose; it is doing no work.
NonfunctioningDistractor,
/// Performance differed across groups after conditioning on ability.
DifFlagged,
/// Marked ambiguous by hand or inferred from partial credit awarded to a
/// distractor during grading.
Ambiguous,
/// The observed difficulty was far from the difficulty you predicted.
DesignMismatch,
}
impl Flag {
/// Every flag.
pub const ALL: [Flag; 11] = [
Flag::NegativeDiscrimination,
Flag::LowDiscrimination,
Flag::DistractorOutperformsKey,
Flag::KeyUnderperforms,
Flag::TooEasy,
Flag::TooHard,
Flag::HighRapidGuess,
Flag::NonfunctioningDistractor,
Flag::DifFlagged,
Flag::Ambiguous,
Flag::DesignMismatch,
];
/// The flag's stable code, matching its YAML spelling.
pub fn as_str(self) -> &'static str {
match self {
Flag::NegativeDiscrimination => "negative_discrimination",
Flag::LowDiscrimination => "low_discrimination",
Flag::DistractorOutperformsKey => "distractor_outperforms_key",
Flag::KeyUnderperforms => "key_underperforms",
Flag::TooEasy => "too_easy",
Flag::TooHard => "too_hard",
Flag::HighRapidGuess => "high_rapid_guess",
Flag::NonfunctioningDistractor => "nonfunctioning_distractor",
Flag::DifFlagged => "dif_flagged",
Flag::Ambiguous => "ambiguous",
Flag::DesignMismatch => "design_mismatch",
}
}
/// Whether the flag should stop an item from being reused as written.
///
/// Distinguishing blocking from advisory flags is what turns analysis into a
/// workflow: a negative discrimination is a keying bug to fix before the item
/// is ever given again, while an easy item is merely uninformative.
///
/// # Returns
///
/// `true` for flags that demand a revision.
pub fn is_blocking(self) -> bool {
matches!(
self,
Flag::NegativeDiscrimination
| Flag::DistractorOutperformsKey
| Flag::Ambiguous
| Flag::KeyUnderperforms
)
}
/// A short explanation of what the flag means and what to do about it.
pub fn advice(self) -> &'static str {
match self {
Flag::NegativeDiscrimination => {
"check the key first, then the stem for a second valid reading"
}
Flag::LowDiscrimination => "expected for anchors; investigate if the level is 3+",
Flag::DistractorOutperformsKey => "the distractor may be the better answer",
Flag::KeyUnderperforms => "strong students split; look for an ambiguity",
Flag::TooEasy => "fine as an anchor, wasteful if you meant it to discriminate",
Flag::TooHard => "check for a missing prerequisite or an unclear stem",
Flag::HighRapidGuess => "position on the form or time pressure, not the item",
Flag::NonfunctioningDistractor => "replace it with a plausible error",
Flag::DifFlagged => "inspect wording for content unrelated to the objective",
Flag::Ambiguous => "rewrite the stem to exclude the second reading",
Flag::DesignMismatch => "update your expectation or revise the item",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levels_order_by_demand() {
assert!(Level::Remember < Level::Create);
assert_eq!(Level::Apply.code(), 3);
}
#[test]
fn every_process_belongs_to_exactly_one_level() {
let mut seen = Vec::new();
for level in Level::ALL {
for p in level.processes() {
assert!(!seen.contains(p), "{p} appears under two levels");
seen.push(*p);
assert_eq!(p.level(), level);
}
}
assert_eq!(seen.len(), 19, "all processes are assigned");
}
#[test]
fn level_process_pairing_is_checked() {
assert!(Level::Apply.allows(CognitiveProcess::Implement));
assert!(!Level::Apply.allows(CognitiveProcess::Recall));
}
#[test]
fn level_serializes_as_an_integer() {
assert_eq!(serde_json::to_string(&Level::Apply).unwrap(), "3");
assert_eq!(serde_json::from_str::<Level>("4").unwrap(), Level::Analyze);
assert!(serde_json::from_str::<Level>("6").is_err());
assert!(serde_json::from_str::<Level>("0").is_err());
}
#[test]
fn processes_use_snake_case() {
assert_eq!(
serde_json::to_string(&CognitiveProcess::Implement).unwrap(),
"\"implement\""
);
assert_eq!(
serde_json::from_str::<Status>("\"in_review\"").unwrap(),
Status::InReview
);
}
}