chore: add dropped question to reports

This commit is contained in:
2026-09-19 23:15:31 -04:00
parent 92c8a8fc75
commit ba84c4d82a
4 changed files with 110 additions and 5 deletions
+41 -1
View File
@@ -172,6 +172,12 @@ pub struct StudentDiagnostic {
/// One row per question, with no question in it. /// One row per question, with no question in it.
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub questions: Vec<QuestionRow>, pub questions: Vec<QuestionRow>,
/// Questions dropped from scoring, in number order.
///
/// Reported separately from the question rows so the document can say once,
/// in prose, why the denominator is smaller than the paper was.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub dropped_questions: Vec<u32>,
/// Which lectures to go back to, the one that would repay the most time /// Which lectures to go back to, the one that would repay the most time
/// first. /// first.
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
@@ -320,6 +326,13 @@ pub struct QuestionRow {
/// Whether it was a bonus question. /// Whether it was a bonus question.
#[serde(skip_serializing_if = "std::ops::Not::not")] #[serde(skip_serializing_if = "std::ops::Not::not")]
pub bonus: bool, pub bonus: bool,
/// Whether it was dropped from scoring after the fact.
///
/// A dropped question still appears in the map, because the student has the
/// paper in front of them and will look for it. What it must not do is
/// appear as an error they made.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub dropped: bool,
/// Whether the student left it blank. /// Whether the student left it blank.
pub blank: bool, pub blank: bool,
/// The share of the class that answered it correctly, when comparison is on. /// The share of the class that answered it correctly, when comparison is on.
@@ -530,6 +543,14 @@ pub fn student(
Vec::new() Vec::new()
}; };
let mut dropped_questions: Vec<u32> = rows
.iter()
.filter(|r| r.dropped)
.map(|r| r.item_number)
.collect();
dropped_questions.sort_unstable();
dropped_questions.dedup();
// Built from the response rows rather than from `questions`, so a report with // Built from the response rows rather than from `questions`, so a report with
// `--no-questions` still says where to go back to; it just does not name the // `--no-questions` still says where to go back to; it just does not name the
// question numbers. // question numbers.
@@ -573,6 +594,7 @@ pub fn student(
strengths, strengths,
focus, focus,
questions, questions,
dropped_questions,
review_lectures, review_lectures,
study, study,
} }
@@ -591,7 +613,10 @@ fn question_row(
opts: &Options, opts: &Options,
) -> QuestionRow { ) -> QuestionRow {
let blank = row.selected.is_empty() && row.eliminated.is_empty(); let blank = row.selected.is_empty() && row.eliminated.is_empty();
let missed = row.credit < 0.999; // A dropped question cannot be missed. Without `counts()` here, a question
// thrown out after the exam still collects per-option feedback explaining an
// error the student is no longer being charged for.
let missed = row.credit < 0.999 && row.counts();
let mut feedback = None; let mut feedback = None;
let mut hint = None; let mut hint = None;
let mut misconception = None; let mut misconception = None;
@@ -673,6 +698,7 @@ fn question_row(
correct: row.correct, correct: row.correct,
credit: row.credit, credit: row.credit,
bonus: row.bonus, bonus: row.bonus,
dropped: row.dropped,
blank, blank,
class_rate: opts class_rate: opts
.comparison .comparison
@@ -926,6 +952,11 @@ pub struct CohortDiagnostic {
pub lectures: Vec<CohortLectureRow>, pub lectures: Vec<CohortLectureRow>,
/// Per-question statistics. /// Per-question statistics.
pub questions: Vec<CohortQuestionRow>, pub questions: Vec<CohortQuestionRow>,
/// Questions dropped from scoring, which are therefore absent from every
/// table above. Recorded so the report says why rather than leaving a gap in
/// the numbering.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub dropped_questions: Vec<u32>,
/// What to do about each question that raised something. /// What to do about each question that raised something.
pub triage: Triage, pub triage: Triage,
/// How the authored expectations did. /// How the authored expectations did.
@@ -1478,6 +1509,13 @@ pub fn cohort(
.filter_map(|item| questions.iter().find(|q| q.number == item.number).cloned()) .filter_map(|item| questions.iter().find(|q| q.number == item.number).cloned())
.collect(); .collect();
let mut dropped_questions: Vec<u32> = set
.all_items()
.into_iter()
.filter(|n| set.item_dropped(*n))
.collect();
dropped_questions.sort_unstable();
let default_options = course.policy.options_per_item; let default_options = course.policy.options_per_item;
let triage = triage(&questions, threshold, default_options); let triage = triage(&questions, threshold, default_options);
let predictions = prediction_summary(analysis); let predictions = prediction_summary(analysis);
@@ -1490,6 +1528,7 @@ pub fn cohort(
distribution: distribution(&percents), distribution: distribution(&percents),
grades, grades,
lectures, lectures,
dropped_questions,
triage, triage,
predictions, predictions,
reliability: ReliabilityRow { reliability: ReliabilityRow {
@@ -2193,6 +2232,7 @@ mod tests {
strengths: Vec::new(), strengths: Vec::new(),
focus: Vec::new(), focus: Vec::new(),
questions: Vec::new(), questions: Vec::new(),
dropped_questions: Vec::new(),
review_lectures: Vec::new(), review_lectures: Vec::new(),
study: Vec::new(), study: Vec::new(),
}) })
+24
View File
@@ -299,6 +299,7 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
value.insert_some("correct", question.correct.map(Value::Bool)); value.insert_some("correct", question.correct.map(Value::Bool));
value.insert("credit", Value::Float(question.credit)); value.insert("credit", Value::Float(question.credit));
value.insert("bonus", Value::Bool(question.bonus)); value.insert("bonus", Value::Bool(question.bonus));
value.insert("dropped", Value::Bool(question.dropped));
value.insert("blank", Value::Bool(question.blank)); value.insert("blank", Value::Bool(question.blank));
value.insert_some("class-rate", question.class_rate.map(Value::Float)); value.insert_some("class-rate", question.class_rate.map(Value::Float));
value.insert( value.insert(
@@ -348,6 +349,17 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
), ),
); );
out.insert(
"dropped-questions",
Value::Array(
diagnostic
.dropped_questions
.iter()
.map(|n| Value::Int(*n as i64))
.collect(),
),
);
out.insert( out.insert(
"review-lectures", "review-lectures",
Value::Array( Value::Array(
@@ -612,6 +624,17 @@ pub fn cohort_value(diagnostic: &CohortDiagnostic, config: &RenderConfig) -> Val
), ),
); );
out.insert(
"dropped-questions",
Value::Array(
diagnostic
.dropped_questions
.iter()
.map(|n| Value::Int(*n as i64))
.collect(),
),
);
let triage_rows = |rows: &[crate::diagnostic::TriageRow]| -> Value { let triage_rows = |rows: &[crate::diagnostic::TriageRow]| -> Value {
Value::Array(rows.iter().map(|row| triage_value(row, content)).collect()) Value::Array(rows.iter().map(|row| triage_value(row, content)).collect())
}; };
@@ -1128,6 +1151,7 @@ mod tests {
gaps: Vec::new(), gaps: Vec::new(),
grades: Vec::new(), grades: Vec::new(),
lectures: Vec::new(), lectures: Vec::new(),
dropped_questions: Vec::new(),
questions: Vec::new(), questions: Vec::new(),
triage: crate::diagnostic::Triage::default(), triage: crate::diagnostic::Triage::default(),
predictions: crate::diagnostic::PredictionSummary::default(), predictions: crate::diagnostic::PredictionSummary::default(),
@@ -167,6 +167,7 @@
band-hit: 11, band-hit: 11,
biggest-surprise: (number: 14, expected: 0.45, observed: 0.86), biggest-surprise: (number: 14, expected: 0.45, observed: 0.86),
), ),
dropped-questions: (),
revise: (), revise: (),
forms: ( forms: (
(id: "A", students: 12, mean: 73.5, sd: 10.2), (id: "A", students: 12, mean: 73.5, sd: 10.2),
@@ -989,6 +990,17 @@
One row per question, in the order they were numbered. Option letters are the One row per question, in the order they were numbered. Option letters are the
bank's, not any one form's. See the guide above for what p, r, and D mean; the bank's, not any one form's. See the guide above for what p, r, and D mean; the
r column is colour-coded against the conventional cut points. r column is colour-coded against the conventional cut points.
#{
let dropped = cb-data.at("dropped-questions", default: ())
if dropped.len() > 0 [
#plural(dropped.len(), "Question", "Questions")
#dropped.map(str).join(", ")
#plural(dropped.len(), "is", "are")
marked dropped in the assessment record, so
#plural(dropped.len(), "it is", "they are")
absent from this table and from every statistic above.
]
}
] ]
#table( #table(
+33 -4
View File
@@ -121,6 +121,7 @@
correct: true, correct: true,
credit: 1.0, credit: 1.0,
bonus: false, bonus: false,
dropped: false,
blank: false, blank: false,
class-rate: 0.91, class-rate: 0.91,
taught-in: (), taught-in: (),
@@ -451,6 +452,26 @@
}, },
) )
#let dropped-questions = cb-data.at("dropped-questions", default: ())
#if dropped-questions.len() > 0 [
#block(above: entry-gap)[
#pad(right: prose-pad)[
#text(size: size-lead)[
#plural(dropped-questions.len(), "Question", "Questions")
#dropped-questions.map(str).join(", ")
#plural(dropped-questions.len(), "was", "were")
dropped after the exam and #plural(dropped-questions.len(), "is", "are")
not part of anyone's score. Your percentage above is out of the
#plural(dropped-questions.len(), "remaining question", "remaining questions").
Nothing you wrote on
#plural(dropped-questions.len(), "it", "them")
counted for or against you.
]
]
]
]
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Levels // Levels
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -750,7 +771,10 @@
#let questions = cb-data.at("questions", default: ()) #let questions = cb-data.at("questions", default: ())
#let question-box(q) = { #let question-box(q) = {
let color = if q.at("blank", default: false) { luma(160) } else if q.at("correct", default: false) == true { let dropped = q.at("dropped", default: false)
let color = if dropped { luma(130) } else if q.at("blank", default: false) {
luma(160)
} else if q.at("correct", default: false) == true {
ok-color ok-color
} else if q.at("credit", default: 0.0) > 0.0 { mid-color } else { bad-color } } else if q.at("credit", default: 0.0) > 0.0 { mid-color } else { bad-color }
box( box(
@@ -763,10 +787,15 @@
#align(center)[ #align(center)[
#text(size: size-small, weight: "bold", fill: color.darken(18%))[#q.number] #text(size: size-small, weight: "bold", fill: color.darken(18%))[#q.number]
#{ #{
let level = q.at("level", default: none) if dropped {
if level != none {
linebreak() linebreak()
text(size: size-tag, fill: luma(120))[L#level] text(size: size-tag, fill: luma(110))[out]
} else {
let level = q.at("level", default: none)
if level != none {
linebreak()
text(size: size-tag, fill: luma(120))[L#level]
}
} }
} }
] ]