feat: cooked up something fierce
Pipeline / check (pull_request) Successful in 3m9s
Pipeline / docs (pull_request) Skipped
Pipeline / nightly (pull_request) Skipped
Pipeline / release (pull_request) Skipped

This commit is contained in:
2026-09-26 18:22:25 -04:00
parent eabc98ad31
commit 5ac1e317c0
26 changed files with 1485 additions and 44 deletions
+29
View File
@@ -134,6 +134,14 @@ pub struct ItemAnalysis {
pub number: u32,
/// The item's global id, when known.
pub item_ref: Option<String>,
/// The option set administered, when the rows agree on one.
///
/// Within one administration an item has one variant, because a placement's
/// distractors are drawn once and shared by every form — only the printed
/// order differs. `None` means the rows disagreed, which happens when a
/// course opts into drawing distractors per form; the statistics below then
/// describe a mixture and cannot be pooled by option set.
pub variant: Option<String>,
/// How many students the item was administered to.
pub n: usize,
/// How many gave a non-blank response.
@@ -553,6 +561,7 @@ pub fn analyze(
item_ref: record
.and_then(|r| r.placement(*number))
.map(|p| p.item.clone()),
variant: one_variant(set, *number),
n,
n_answered,
blank_rate: blank as f64 / responded as f64,
@@ -906,6 +915,25 @@ fn reliability(coded: &[Vec<f64>], totals: &[f64], p_values: &[f64], rpbs: &[f64
/// # Returns
///
/// The letters that appear on full-credit responses.
/// The single variant every row for one question names, if they agree.
///
/// Disagreement is not an error, it is a fact about the administration: a course
/// that draws distractors per form has two option sets under one question
/// number, and no pooled statistic describes both. Returning `None` is what
/// keeps the per-variant records from claiming otherwise.
fn one_variant(set: &ResponseSet, number: u32) -> Option<String> {
let mut seen: Option<&str> = None;
for row in set.rows.iter().filter(|r| r.item_number == number) {
let variant = row.variant.as_deref()?;
match seen {
None => seen = Some(variant),
Some(first) if first == variant => {}
Some(_) => return None,
}
}
seen.map(str::to_string)
}
fn infer_key(rows: &[&crate::responses::Response]) -> Vec<String> {
let mut out: BTreeSet<String> = BTreeSet::new();
for r in rows {
@@ -1001,6 +1029,7 @@ mod tests {
item_number: number,
item_ref: None,
item_version: None,
variant: None,
selected: if letter.is_empty() {
vec![]
} else {