// SPDX-License-Identifier: Prosperity-3.0.0 // Copyright Scientific Computing Studio // Source: https://git.scient.ing/education/coursebank //! The pedagogical vocabulary: levels, cognitive processes, error types, and //! workflow states. 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 { 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 for Level { type Error = String; fn try_from(v: u8) -> Result { 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 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::("4").unwrap(), Level::Analyze); assert!(serde_json::from_str::("6").is_err()); assert!(serde_json::from_str::("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::("\"in_review\"").unwrap(), Status::InReview ); } }