feat: initial package draft
This commit is contained in:
@@ -0,0 +1,831 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user