diff --git a/src/analysis/diagnostic.rs b/src/analysis/diagnostic.rs index 0c3be4e..9db39fa 100644 --- a/src/analysis/diagnostic.rs +++ b/src/analysis/diagnostic.rs @@ -172,6 +172,12 @@ pub struct StudentDiagnostic { /// One row per question, with no question in it. #[serde(skip_serializing_if = "Vec::is_empty")] pub questions: Vec, + /// 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, /// Which lectures to go back to, the one that would repay the most time /// first. #[serde(skip_serializing_if = "Vec::is_empty")] @@ -320,6 +326,13 @@ pub struct QuestionRow { /// Whether it was a bonus question. #[serde(skip_serializing_if = "std::ops::Not::not")] 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. pub blank: bool, /// The share of the class that answered it correctly, when comparison is on. @@ -530,6 +543,14 @@ pub fn student( Vec::new() }; + let mut dropped_questions: Vec = 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 // `--no-questions` still says where to go back to; it just does not name the // question numbers. @@ -573,6 +594,7 @@ pub fn student( strengths, focus, questions, + dropped_questions, review_lectures, study, } @@ -591,7 +613,10 @@ fn question_row( opts: &Options, ) -> QuestionRow { 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 hint = None; let mut misconception = None; @@ -673,6 +698,7 @@ fn question_row( correct: row.correct, credit: row.credit, bonus: row.bonus, + dropped: row.dropped, blank, class_rate: opts .comparison @@ -926,6 +952,11 @@ pub struct CohortDiagnostic { pub lectures: Vec, /// Per-question statistics. pub questions: Vec, + /// 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, /// What to do about each question that raised something. pub triage: Triage, /// How the authored expectations did. @@ -1478,6 +1509,13 @@ pub fn cohort( .filter_map(|item| questions.iter().find(|q| q.number == item.number).cloned()) .collect(); + let mut dropped_questions: Vec = 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 triage = triage(&questions, threshold, default_options); let predictions = prediction_summary(analysis); @@ -1490,6 +1528,7 @@ pub fn cohort( distribution: distribution(&percents), grades, lectures, + dropped_questions, triage, predictions, reliability: ReliabilityRow { @@ -2193,6 +2232,7 @@ mod tests { strengths: Vec::new(), focus: Vec::new(), questions: Vec::new(), + dropped_questions: Vec::new(), review_lectures: Vec::new(), study: Vec::new(), }) diff --git a/src/export/typst/diagnostic.rs b/src/export/typst/diagnostic.rs index 08ac1db..a321ad3 100644 --- a/src/export/typst/diagnostic.rs +++ b/src/export/typst/diagnostic.rs @@ -299,6 +299,7 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V value.insert_some("correct", question.correct.map(Value::Bool)); value.insert("credit", Value::Float(question.credit)); value.insert("bonus", Value::Bool(question.bonus)); + value.insert("dropped", Value::Bool(question.dropped)); value.insert("blank", Value::Bool(question.blank)); value.insert_some("class-rate", question.class_rate.map(Value::Float)); 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( "review-lectures", 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 { Value::Array(rows.iter().map(|row| triage_value(row, content)).collect()) }; @@ -1128,6 +1151,7 @@ mod tests { gaps: Vec::new(), grades: Vec::new(), lectures: Vec::new(), + dropped_questions: Vec::new(), questions: Vec::new(), triage: crate::diagnostic::Triage::default(), predictions: crate::diagnostic::PredictionSummary::default(), diff --git a/src/export/typst/templates/cohort-report.typ b/src/export/typst/templates/cohort-report.typ index f0c5449..420ae8e 100644 --- a/src/export/typst/templates/cohort-report.typ +++ b/src/export/typst/templates/cohort-report.typ @@ -167,6 +167,7 @@ band-hit: 11, biggest-surprise: (number: 14, expected: 0.45, observed: 0.86), ), + dropped-questions: (), revise: (), forms: ( (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 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. + #{ + 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( diff --git a/src/export/typst/templates/student-report.typ b/src/export/typst/templates/student-report.typ index 237aae4..7a898c8 100644 --- a/src/export/typst/templates/student-report.typ +++ b/src/export/typst/templates/student-report.typ @@ -121,6 +121,7 @@ correct: true, credit: 1.0, bonus: false, + dropped: false, blank: false, class-rate: 0.91, 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 // ───────────────────────────────────────────────────────────────────────────── @@ -750,7 +771,10 @@ #let questions = cb-data.at("questions", default: ()) #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 } else if q.at("credit", default: 0.0) > 0.0 { mid-color } else { bad-color } box( @@ -763,10 +787,15 @@ #align(center)[ #text(size: size-small, weight: "bold", fill: color.darken(18%))[#q.number] #{ - let level = q.at("level", default: none) - if level != none { + if dropped { 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] + } } } ]