832 lines
28 KiB
Rust
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);
|
|
}
|
|
}
|