Files
coursebank/src/data/responses.rs
T
2026-08-05 23:18:16 -04:00

832 lines
28 KiB
Rust

//! 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<Date>,
/// Which form the student took, when forms were used.
pub form: Option<String>,
/// The identifier analysis groups by. A pseudonym when pseudonymizing.
pub student_key: String,
/// The institutional student id, absent when pseudonymized.
pub sid: Option<String>,
/// The student's name, absent when pseudonymized.
pub name: Option<String>,
/// The student's email, absent when pseudonymized.
pub email: Option<String>,
/// Section or lab, kept because it is the grouping most likely to reveal a
/// delivery problem rather than a learning one.
pub section: Option<String>,
/// 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<String>,
/// The item version as administered.
pub item_version: Option<u32>,
/// Option letters the student chose.
pub selected: Vec<String>,
/// Option letters the student eliminated, for elimination-scored items.
pub eliminated: Vec<String>,
/// 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<bool>,
/// 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<f64>,
/// The item's level, denormalized so analysis need not carry the catalog.
pub level: Option<Level>,
/// The item's learning objectives, denormalized for per-objective mastery.
pub learning_objectives: Vec<String>,
/// The item's topics, denormalized.
pub topics: Vec<String>,
/// 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<bool> {
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<Response>,
/// Non-fatal problems: unmatched columns, students with no responses,
/// question numbers absent from the assessment record.
pub warnings: Vec<String>,
}
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<String> {
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<u32> {
let set: BTreeSet<u32> = 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<u32> {
let set: BTreeSet<u32> = 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<u32, f64> = 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<u32> = 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<u32, usize> =
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<String> {
let mut warnings = Vec::new();
let mut unmatched: BTreeSet<u32> = 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<String> = 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<String> {
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<String>,
/// Question numbers, one per column.
pub items: Vec<u32>,
/// Credit fractions; `None` for a missing response.
pub credit: Vec<Vec<Option<f64>>>,
/// Dichotomous codes; `None` for a missing response.
pub coded: Vec<Vec<Option<u8>>>,
}
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<f64> {
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<f64> {
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<Option<u8>> {
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<String> {
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<String> {
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);
}
}