//! The canonical response row. //! //! Every grading platform exports a different shape. Gradescope gives one file //! per question, in wide form, with a column per rubric item. Canvas gives one //! enormous file per quiz, in wide form, with two columns per question and answer //! *text* instead of option letters. Neither shape is analyzable. //! //! So both are normalized into the long form defined here: one row per student //! per item. Long form is what item analysis, IRT, and per-objective mastery all //! want, it survives a question being added or dropped without changing the //! schema, and it appends cleanly across terms — which is the whole point, since //! item statistics only become trustworthy once several administrations are //! pooled. //! //! Two fields deserve comment. //! //! `credit` is a *fraction* in `0.0..=1.0`, not points. Storing the fraction //! keeps the response independent of the points an item happened to be worth on //! one exam, so pooling across administrations that weighted an item differently //! is still valid. `score` carries the points actually awarded. //! //! `student_key` is whatever identifier analysis should group by, and it may be a //! pseudonym. The real SID lives in `sid`, which is dropped when pseudonymizing. use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; use crate::assessment::AssessmentFile; use crate::catalog::Catalog; use crate::date::Date; use crate::hash::pseudonym; use crate::taxonomy::Level; /// One student's response to one item on one administration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Response { /// Identifies this administration: course slug, term, and assessment id. /// Rows from different administrations of the same exam differ here, which is /// what makes pooled analysis separable again later. pub administration_id: String, /// The course code. pub course: String, /// The term, e.g. `2026S`. pub term: String, /// The assessment id. pub assessment_id: String, /// The administration date. pub date: Option, /// Which form the student took, when forms were used. pub form: Option, /// The identifier analysis groups by. A pseudonym when pseudonymizing. pub student_key: String, /// The institutional student id, absent when pseudonymized. pub sid: Option, /// The student's name, absent when pseudonymized. pub name: Option, /// The student's email, absent when pseudonymized. pub email: Option, /// Section or lab, kept because it is the grouping most likely to reveal a /// delivery problem rather than a learning one. pub section: Option, /// The question number on the form, which is the join key to the record. pub item_number: u32, /// The item's global id, once resolved against an assessment record. pub item_ref: Option, /// The item version as administered. pub item_version: Option, /// Option letters the student chose. pub selected: Vec, /// Option letters the student eliminated, for elimination-scored items. pub eliminated: Vec, /// Whether the response earned full credit. `None` when it cannot be /// determined, e.g. a blank response on an item with no recorded key. pub correct: Option, /// Fraction of the item's points earned, in `0.0..=1.0`. May exceed nothing /// and may go negative on elimination scoring. pub credit: f64, /// Points the item was worth as administered. pub points_possible: f64, /// Points awarded, authoritative from the platform where available. pub score: f64, /// Seconds spent, when the platform reports it. pub response_time_seconds: Option, /// The item's level, denormalized so analysis need not carry the catalog. pub level: Option, /// The item's learning objectives, denormalized for per-objective mastery. pub learning_objectives: Vec, /// The item's topics, denormalized. pub topics: Vec, /// Whether the item was bonus, and so excluded from the scored total. pub bonus: bool, /// Whether the item was dropped after the fact. pub dropped: bool, } impl Response { /// Whether this row should count toward scored totals and item statistics. /// /// # Returns /// /// `true` for a scored, undropped item. pub fn counts(&self) -> bool { !self.bonus && !self.dropped } /// The response coded for a dichotomous model. /// /// Partial credit is rounded toward the majority: a half-credit response is /// coded incorrect. IRT here is dichotomous, and pretending otherwise would /// misstate the model rather than the data. /// /// # Returns /// /// `Some(true)` for full credit, `Some(false)` for less, `None` when unknown. pub fn dichotomous(&self) -> Option { match self.correct { Some(c) => Some(c), None if self.credit > 0.0 => Some(self.credit >= 0.999), None => None, } } /// The selected options as a comma-joined string, for flat storage. pub fn selected_joined(&self) -> String { self.selected.join(",") } } /// A set of responses plus anything worth telling the user about the ingest. #[derive(Debug, Clone, Default)] pub struct ResponseSet { /// The rows, in ingest order. pub rows: Vec, /// Non-fatal problems: unmatched columns, students with no responses, /// question numbers absent from the assessment record. pub warnings: Vec, } impl ResponseSet { /// Creates an empty set. pub fn new() -> ResponseSet { ResponseSet::default() } /// Appends another set, keeping its warnings. /// /// # Arguments /// /// * `other` - the set to absorb. pub fn absorb(&mut self, other: ResponseSet) { self.rows.extend(other.rows); self.warnings.extend(other.warnings); } /// The distinct student keys, in sorted order. pub fn students(&self) -> Vec { let set: BTreeSet<&str> = self.rows.iter().map(|r| r.student_key.as_str()).collect(); set.into_iter().map(|s| s.to_string()).collect() } /// The distinct item numbers that count toward the scored total, sorted. pub fn scored_items(&self) -> Vec { let set: BTreeSet = self .rows .iter() .filter(|r| r.counts()) .map(|r| r.item_number) .collect(); set.into_iter().collect() } /// All distinct item numbers, sorted. pub fn all_items(&self) -> Vec { let set: BTreeSet = self.rows.iter().map(|r| r.item_number).collect(); set.into_iter().collect() } /// Every row for one item, in student order. /// /// # Arguments /// /// * `number` - the question number. /// /// # Returns /// /// The matching rows. pub fn for_item(&self, number: u32) -> Vec<&Response> { let mut v: Vec<&Response> = self .rows .iter() .filter(|r| r.item_number == number) .collect(); v.sort_by(|a, b| a.student_key.cmp(&b.student_key)); v } /// Every row for one student, in item order. /// /// # Arguments /// /// * `key` - the student key. /// /// # Returns /// /// The matching rows. pub fn for_student(&self, key: &str) -> Vec<&Response> { let mut v: Vec<&Response> = self.rows.iter().filter(|r| r.student_key == key).collect(); v.sort_by_key(|r| r.item_number); v } /// Total points a student earned on scored items. /// /// # Arguments /// /// * `key` - the student key. /// /// # Returns /// /// The sum of `score` over scored, undropped items. pub fn scored_total(&self, key: &str) -> f64 { self.rows .iter() .filter(|r| r.student_key == key && r.counts()) .map(|r| r.score) .sum() } /// Bonus points a student earned. pub fn bonus_total(&self, key: &str) -> f64 { self.rows .iter() .filter(|r| r.student_key == key && r.bonus && !r.dropped) .map(|r| r.score) .sum() } /// Points available on scored items, taken from the most generous row seen /// for each item so a student who skipped an item still has a denominator. pub fn points_available(&self) -> f64 { let mut per_item: BTreeMap = BTreeMap::new(); for r in self.rows.iter().filter(|r| r.counts()) { let e = per_item.entry(r.item_number).or_insert(0.0); if r.points_possible > *e { *e = r.points_possible; } } per_item.values().sum() } /// Builds the response matrix for psychometrics. /// /// # Arguments /// /// * `include_bonus` - whether bonus items belong in the matrix. They /// normally do not: bonus items are usually hard and optional, so including /// them inflates the appearance of a low-ability tail. /// /// # Returns /// /// The matrix, students by items. pub fn matrix(&self, include_bonus: bool) -> Matrix { let students = self.students(); let items: Vec = if include_bonus { self.all_items() .into_iter() .filter(|n| !self.item_dropped(*n)) .collect() } else { self.scored_items() }; let student_index: BTreeMap<&str, usize> = students .iter() .enumerate() .map(|(i, s)| (s.as_str(), i)) .collect(); let item_index: BTreeMap = items.iter().enumerate().map(|(i, n)| (*n, i)).collect(); let mut credit = vec![vec![None; items.len()]; students.len()]; let mut coded = vec![vec![None; items.len()]; students.len()]; for r in &self.rows { let Some(&si) = student_index.get(r.student_key.as_str()) else { continue; }; let Some(&ii) = item_index.get(&r.item_number) else { continue; }; credit[si][ii] = Some(r.credit); coded[si][ii] = r.dichotomous().map(|c| if c { 1u8 } else { 0u8 }); } Matrix { students, items, credit, coded, } } /// Whether every row for an item is marked dropped. /// /// # Arguments /// /// * `number` - the question number. /// /// # Returns /// /// `true` when the item was dropped. pub fn item_dropped(&self, number: u32) -> bool { let mut any = false; for r in self.rows.iter().filter(|r| r.item_number == number) { any = true; if !r.dropped { return false; } } any } /// Attaches item metadata from an assessment record and the catalog. /// /// Ingest knows question numbers; only the record knows which item a number /// referred to. Doing this as a separate pass means an export can be parsed /// and inspected before the record is written, which is the order people /// actually work in. /// /// # Arguments /// /// * `record` - the assessment record. /// * `catalog` - the loaded course, for levels, objectives, and topics. /// /// # Returns /// /// Warnings for numbers absent from the record and for keys that disagree /// with the record. pub fn enrich(&mut self, record: &AssessmentFile, catalog: Option<&Catalog>) -> Vec { let mut warnings = Vec::new(); let mut unmatched: BTreeSet = BTreeSet::new(); let default_points = catalog .map(|c| c.course.policy.points_per_item) .unwrap_or(1.0); for r in &mut self.rows { let Some(p) = record.placement(r.item_number) else { unmatched.insert(r.item_number); continue; }; r.item_ref = Some(p.item.clone()); r.item_version = p.version; r.bonus = r.bonus || p.bonus; r.dropped = r.dropped || p.dropped; if let Some(points) = p.points { // The record is authoritative for points as administered; the // export sometimes carries a stale maximum. if (points - r.points_possible).abs() > 1e-9 && r.points_possible > 0.0 { let ratio = r.credit; r.points_possible = points; r.score = ratio * points; } if r.points_possible == 0.0 { r.points_possible = points; } } if let Some(cat) = catalog { if let Some(entry) = cat.get(&p.item) { r.level = Some(entry.item.level); r.learning_objectives = if p.learning_objectives.is_empty() { entry.item.learning_objectives.clone() } else { p.learning_objectives.clone() }; r.topics = entry.item.topics.clone(); if r.points_possible == 0.0 && !p.bonus { r.points_possible = entry.item.points(default_points); } } } else { r.level = p.level; r.learning_objectives = p.learning_objectives.clone(); } // Apply the record's credit overrides, which is how a decision to // award partial credit after the fact becomes visible in analysis. if !p.credit_overrides.is_empty() && r.selected.len() == 1 { if let Some(over) = p.credit_overrides.get(&r.selected[0]) { if (*over - r.credit).abs() > 1e-9 { r.credit = *over; r.score = *over * r.points_possible; r.correct = Some(*over >= 0.999); } } } } if !unmatched.is_empty() { let list: Vec = unmatched.iter().map(|n| n.to_string()).collect(); warnings.push(format!( "question number(s) {} appear in the export but not in the assessment record; \ they will be analyzed without item metadata", list.join(", ") )); } self.warnings.extend(warnings.clone()); warnings } /// Replaces identifiers with keyed pseudonyms. /// /// The salt must be kept outside the course repository. Hashing a seven-digit /// student id without a key is not de-identification: the whole space can be /// enumerated in under a second, so anyone with the file recovers every id. /// /// # Arguments /// /// * `salt` - the HMAC key. pub fn pseudonymize(&mut self, salt: &[u8]) { for r in &mut self.rows { let source = r .sid .clone() .or_else(|| r.email.clone()) .unwrap_or_else(|| r.student_key.clone()); r.student_key = pseudonym(salt, &source, 12); r.sid = None; r.name = None; r.email = None; } } /// The administration ids present, sorted. pub fn administrations(&self) -> Vec { let set: BTreeSet<&str> = self .rows .iter() .map(|r| r.administration_id.as_str()) .collect(); set.into_iter().map(|s| s.to_string()).collect() } } /// Builds an administration id. /// /// # Arguments /// /// * `course` - the course code. /// * `term` - the term. /// * `assessment` - the assessment id. /// /// # Returns /// /// A stable identifier such as `BIOSC1540/2026S/exam-4`. pub fn administration_id(course: &str, term: &str, assessment: &str) -> String { format!("{course}/{term}/{assessment}") } /// A response matrix, students by items. #[derive(Debug, Clone)] pub struct Matrix { /// Student keys, one per row. pub students: Vec, /// Question numbers, one per column. pub items: Vec, /// Credit fractions; `None` for a missing response. pub credit: Vec>>, /// Dichotomous codes; `None` for a missing response. pub coded: Vec>>, } impl Matrix { /// The number of examinees. pub fn n_students(&self) -> usize { self.students.len() } /// The number of items. pub fn n_items(&self) -> usize { self.items.len() } /// Per-student total of credit fractions, treating missing as zero. /// /// # Returns /// /// One total per student, in row order. pub fn totals(&self) -> Vec { self.credit .iter() .map(|row| row.iter().map(|c| c.unwrap_or(0.0)).sum()) .collect() } /// Per-student count of items answered correctly. pub fn correct_counts(&self) -> Vec { self.coded .iter() .map(|row| row.iter().map(|c| c.unwrap_or(0) as f64).sum()) .collect() } /// The column for one item, by index. /// /// # Arguments /// /// * `j` - the column index. /// /// # Returns /// /// The dichotomous codes down that column. pub fn column(&self, j: usize) -> Vec> { self.coded.iter().map(|row| row[j]).collect() } /// Whether the matrix has enough data to analyze at all. /// /// # Returns /// /// `true` when there is at least one student and one item. pub fn is_analyzable(&self) -> bool { self.n_students() > 0 && self.n_items() > 0 } } /// A flat record for CSV and Parquet storage. /// /// The nested vectors on [`Response`] do not survive a columnar format, so they /// are joined here. This is the schema written to disk, and it is deliberately /// wide and denormalized: it is a fact table, meant to be appended to and read /// by other tools, not a normalized database. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FlatResponse { /// Identifies the administration. pub administration_id: String, /// The course code. pub course: String, /// The term. pub term: String, /// The assessment id. pub assessment_id: String, /// The date as `YYYY-MM-DD`, empty when unknown. pub date: String, /// The form id, empty when there were no forms. pub form: String, /// The grouping key. pub student_key: String, /// The student id, empty when pseudonymized. pub sid: String, /// The student email, empty when pseudonymized. pub email: String, /// The section, empty when unknown. pub section: String, /// The question number. pub item_number: u32, /// The item's global id, empty when unresolved. pub item_ref: String, /// The item version, 0 when unknown. pub item_version: u32, /// Comma-joined selected letters. pub selected: String, /// Comma-joined eliminated letters. pub eliminated: String, /// `1`, `0`, or empty when unknown. pub correct: String, /// Credit fraction. pub credit: f64, /// Points possible. pub points_possible: f64, /// Points awarded. pub score: f64, /// Seconds spent, empty when unknown. pub response_time_seconds: String, /// The level code 1..5, 0 when unknown. pub level: u8, /// Comma-joined objective ids. pub learning_objectives: String, /// Comma-joined topics. pub topics: String, /// Whether the item was bonus. pub bonus: bool, /// Whether the item was dropped. pub dropped: bool, } impl FlatResponse { /// Flattens a response. /// /// # Arguments /// /// * `r` - the response. /// /// # Returns /// /// The flat record. pub fn from_response(r: &Response) -> FlatResponse { FlatResponse { administration_id: r.administration_id.clone(), course: r.course.clone(), term: r.term.clone(), assessment_id: r.assessment_id.clone(), date: r.date.map(|d| d.to_string()).unwrap_or_default(), form: r.form.clone().unwrap_or_default(), student_key: r.student_key.clone(), sid: r.sid.clone().unwrap_or_default(), email: r.email.clone().unwrap_or_default(), section: r.section.clone().unwrap_or_default(), item_number: r.item_number, item_ref: r.item_ref.clone().unwrap_or_default(), item_version: r.item_version.unwrap_or(0), selected: r.selected.join(","), eliminated: r.eliminated.join(","), correct: match r.correct { Some(true) => "1".to_string(), Some(false) => "0".to_string(), None => String::new(), }, credit: r.credit, points_possible: r.points_possible, score: r.score, response_time_seconds: r .response_time_seconds .map(|s| format!("{s:.1}")) .unwrap_or_default(), level: r.level.map(|l| l.code()).unwrap_or(0), learning_objectives: r.learning_objectives.join(","), topics: r.topics.join(","), bonus: r.bonus, dropped: r.dropped, } } /// Rebuilds a response from its flat form. /// /// # Returns /// /// The response. Unparseable optional fields become `None` rather than /// failing the read, because a hand-edited CSV should still load. pub fn to_response(&self) -> Response { let split = |s: &str| -> Vec { s.split(',') .map(|p| p.trim()) .filter(|p| !p.is_empty()) .map(|p| p.to_string()) .collect() }; Response { administration_id: self.administration_id.clone(), course: self.course.clone(), term: self.term.clone(), assessment_id: self.assessment_id.clone(), date: self.date.parse().ok(), form: none_if_empty(&self.form), student_key: self.student_key.clone(), sid: none_if_empty(&self.sid), name: None, email: none_if_empty(&self.email), section: none_if_empty(&self.section), item_number: self.item_number, item_ref: none_if_empty(&self.item_ref), item_version: if self.item_version == 0 { None } else { Some(self.item_version) }, selected: split(&self.selected), eliminated: split(&self.eliminated), correct: match self.correct.as_str() { "1" | "true" => Some(true), "0" | "false" => Some(false), _ => None, }, credit: self.credit, points_possible: self.points_possible, score: self.score, response_time_seconds: self.response_time_seconds.parse().ok(), level: Level::from_code(self.level), learning_objectives: split(&self.learning_objectives), topics: split(&self.topics), bonus: self.bonus, dropped: self.dropped, } } } /// `None` for an empty string, `Some` otherwise. fn none_if_empty(s: &str) -> Option { if s.is_empty() { None } else { Some(s.to_string()) } } #[cfg(test)] mod tests { use super::*; fn row(student: &str, number: u32, credit: f64) -> Response { Response { administration_id: "C/2026S/e1".into(), course: "C".into(), term: "2026S".into(), assessment_id: "e1".into(), date: None, form: None, student_key: student.into(), sid: Some(format!("sid-{student}")), name: None, email: None, section: None, item_number: number, item_ref: None, item_version: None, selected: vec!["A".into()], eliminated: vec![], correct: Some(credit >= 0.999), credit, points_possible: 2.0, score: credit * 2.0, response_time_seconds: None, level: None, learning_objectives: vec![], topics: vec![], bonus: false, dropped: false, } } #[test] fn matrix_is_students_by_items() { let mut set = ResponseSet::new(); set.rows.push(row("s1", 1, 1.0)); set.rows.push(row("s1", 2, 0.0)); set.rows.push(row("s2", 1, 1.0)); set.rows.push(row("s2", 2, 1.0)); let m = set.matrix(false); assert_eq!(m.n_students(), 2); assert_eq!(m.n_items(), 2); assert_eq!(m.coded[0], vec![Some(1), Some(0)]); assert_eq!(m.correct_counts(), vec![1.0, 2.0]); } #[test] fn missing_responses_stay_missing() { let mut set = ResponseSet::new(); set.rows.push(row("s1", 1, 1.0)); set.rows.push(row("s2", 2, 1.0)); let m = set.matrix(false); // s1 never answered item 2, so that cell is absent rather than zero. assert_eq!(m.coded[0][1], None); assert_eq!(m.coded[1][0], None); // Totals treat missing as zero, which is right for scoring. assert_eq!(m.totals(), vec![1.0, 1.0]); } #[test] fn bonus_items_are_excluded_by_default() { let mut set = ResponseSet::new(); set.rows.push(row("s1", 1, 1.0)); let mut bonus = row("s1", 2, 1.0); bonus.bonus = true; set.rows.push(bonus); assert_eq!(set.matrix(false).n_items(), 1); assert_eq!(set.matrix(true).n_items(), 2); assert_eq!(set.scored_total("s1"), 2.0); assert_eq!(set.bonus_total("s1"), 2.0); } #[test] fn dropped_items_leave_the_matrix() { let mut set = ResponseSet::new(); let mut r = row("s1", 1, 0.0); r.dropped = true; set.rows.push(r); set.rows.push(row("s1", 2, 1.0)); assert_eq!(set.matrix(false).items, vec![2]); assert!(set.item_dropped(1)); assert!(!set.item_dropped(2)); } #[test] fn partial_credit_codes_as_incorrect_for_irt() { let mut r = row("s1", 1, 0.5); r.correct = None; assert_eq!(r.dichotomous(), Some(false)); r.credit = 1.0; assert_eq!(r.dichotomous(), Some(true)); } #[test] fn pseudonymizing_removes_identifiers() { let mut set = ResponseSet::new(); set.rows.push(row("s1", 1, 1.0)); let before = set.rows[0].student_key.clone(); set.pseudonymize(b"secret-salt"); assert_ne!(set.rows[0].student_key, before); assert!(set.rows[0].student_key.starts_with("s-")); assert!(set.rows[0].sid.is_none()); assert!(set.rows[0].name.is_none()); } #[test] fn flattening_round_trips() { let r = row("s1", 3, 0.5); let flat = FlatResponse::from_response(&r); let back = flat.to_response(); assert_eq!(back.student_key, "s1"); assert_eq!(back.item_number, 3); assert_eq!(back.credit, 0.5); assert_eq!(back.selected, vec!["A".to_string()]); assert_eq!(back.correct, Some(false)); } #[test] fn points_available_uses_the_largest_seen_per_item() { let mut set = ResponseSet::new(); set.rows.push(row("s1", 1, 1.0)); let mut r = row("s2", 1, 1.0); r.points_possible = 3.0; set.rows.push(r); assert_eq!(set.points_available(), 3.0); } }