From c06d5caa8ce85a6111ff95f8e63b52647b566a21 Mon Sep 17 00:00:00 2001 From: Alex Maldonado Date: Sat, 19 Sep 2026 21:30:04 -0400 Subject: [PATCH] refactor: improve student report --- src/analysis/diagnostic.rs | 356 +++++++- src/cli.rs | 15 + src/commands/analysis.rs | 17 + src/export/typst/diagnostic.rs | 96 +- src/export/typst/templates/student-report.typ | 860 +++++++++++++----- 5 files changed, 1074 insertions(+), 270 deletions(-) diff --git a/src/analysis/diagnostic.rs b/src/analysis/diagnostic.rs index 25c6361..58afcd3 100644 --- a/src/analysis/diagnostic.rs +++ b/src/analysis/diagnostic.rs @@ -23,9 +23,21 @@ //! exam is given. //! //! What a student does get, per missed question: the number, its level, the -//! objectives it measured, whether they answered it, and the feedback written for -//! the specific option they chose. That last part is why authoring distractors -//! carefully pays off twice. +//! objectives it measured and what those objectives ask, whether they answered +//! it, the feedback written for the specific option they chose, the hint written +//! for that same option, and the item's own `review` citations. That is why +//! authoring distractors carefully pays off twice. +//! +//! Two further fields are available and off by default, because they are the two +//! that trade a student's understanding against reusing the question. The +//! misconception is written to you about the student; the worked solution is the +//! solutions document. See [`Options::misconceptions`] and [`Options::solutions`]. +//! +//! It is worth being clear about what the default already discloses. The +//! per-option feedback on a missed question routinely names the right answer, +//! because that is what makes it useful. A report handed to sixty students is +//! therefore already a partial answer key for the questions those students +//! missed, with or without the two optional fields. //! //! Two consequences of that invariant are worth stating because they are easy to //! undo by accident: @@ -46,6 +58,11 @@ //! resolves each weak objective through the course's own reading registry, so a //! student is pointed at `KKW §6.2` with the sentence you wrote about what to take //! from it, rather than at the name of a chapter. +//! +//! [`LectureFocus`] answers the question a student actually asks, which is where +//! to start. It ranks the lectures behind the missed questions by how many +//! objectives went wrong in each, so a reading list of eleven sections becomes an +//! ordered afternoon. use std::collections::{BTreeMap, BTreeSet}; @@ -54,8 +71,9 @@ use serde::Serialize; use crate::assessment::AssessmentFile; use crate::catalog::Catalog; use crate::classical::Analysis; -use crate::course::{CourseFile, ReadingRole}; +use crate::course::{CourseFile, ReadingRole, Reference}; use crate::irt::Fit; +use crate::item::Citation; use crate::responses::{Response, ResponseSet}; use crate::students::{Cohort, Mastery, StudentSummary}; use crate::taxonomy::Level; @@ -72,6 +90,27 @@ pub struct Options { pub questions: bool, /// Whether to include the feedback written for the option the student chose. pub feedback: bool, + /// Whether to include the hint written for that option. + /// + /// On by default. A hint is the question you would ask a student who was + /// reconsidering that option, so it gives them somewhere to start rather than + /// a verdict to accept. + pub hints: bool, + /// Whether to name the misconception the chosen distractor was written to + /// catch. + /// + /// Off by default, and not because it is unsafe. The text is written to you, + /// about the student, in the third person, and next to the feedback written + /// for them it reads like a chart note. + pub misconceptions: bool, + /// Whether to include the worked solution for a missed question. + /// + /// Off by default. [`crate::item::Solution::explanation`] is the derivation, + /// the estimate, and the argument for the key over its neighbours: the body of + /// the solutions document. Turning this on hands that to every student who + /// missed the question, which is the right call for a question you will not + /// use again and the wrong one for a bank you reuse each term. + pub solutions: bool, /// How many objectives to build a study plan for. pub focus_limit: usize, /// How many readings to list per objective. @@ -86,6 +125,9 @@ impl Default for Options { comparison: true, questions: true, feedback: true, + hints: true, + misconceptions: false, + solutions: false, focus_limit: 4, readings_per_objective: 2, ability: false, @@ -105,6 +147,12 @@ pub struct StudentDiagnostic { /// The institutional id, when identifiers were kept. #[serde(skip_serializing_if = "Option::is_none")] pub sid: Option, + /// Their email, when the export carried one and identifiers were kept. + /// + /// Absent rather than blank when the platform did not report it, so a + /// template prints nothing instead of an empty label. + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, /// Which form they sat. #[serde(skip_serializing_if = "Option::is_none")] pub form: Option, @@ -124,6 +172,10 @@ pub struct StudentDiagnostic { /// One row per question, with no question in it. #[serde(skip_serializing_if = "Vec::is_empty")] pub questions: Vec, + /// Which lectures to go back to, the one that would repay the most time + /// first. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub review_lectures: Vec, /// What to read, grouped by objective. #[serde(skip_serializing_if = "Vec::is_empty")] pub study: Vec, @@ -253,6 +305,13 @@ pub struct QuestionRow { pub level: Option, /// The objectives it measured. pub objectives: Vec, + /// What those objectives ask, in the words the course uses with students. + /// + /// The objective, not the question. It is printed in full in the objectives + /// table already; repeating it next to a missed question saves a student + /// working out which of thirty-six rows this one belonged to. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub objective_texts: Vec, /// Whether it was answered correctly. #[serde(skip_serializing_if = "Option::is_none")] pub correct: Option, @@ -269,9 +328,68 @@ pub struct QuestionRow { /// The feedback written for the option this student chose. #[serde(skip_serializing_if = "Option::is_none")] pub feedback: Option, + /// The hint written for that option: where to look, not what the answer was. + #[serde(skip_serializing_if = "Option::is_none")] + pub hint: Option, + /// The misconception that option was written to catch, when + /// [`Options::misconceptions`] is on. + #[serde(skip_serializing_if = "Option::is_none")] + pub misconception: Option, + /// The worked solution, when [`Options::solutions`] is on. + #[serde(skip_serializing_if = "Option::is_none")] + pub worked: Option, /// Where the material was taught. #[serde(skip_serializing_if = "Vec::is_empty")] pub taught_in: Vec, + /// What to read again about this question, from the item's own `review` + /// citations rather than from the objective's reading list. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub review: Vec, +} + +/// One citation to read again after missing a question. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct ItemReading { + /// A short citation, e.g. `KKW §2.1`. + pub citation: String, + /// The work's full title, for a student who does not recognise the label. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// A link, when the citation resolves to one. + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +/// One lecture worth going back to, with the evidence for saying so. +/// +/// Ranked by how many *objectives* went wrong rather than how many questions +/// did. Missing four questions on one objective is one thing to relearn; missing +/// four questions across four objectives is four, and the second is the lecture +/// to reread first even though the arithmetic looks identical. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct LectureFocus { + /// The lecture id, e.g. `L1.4`. + pub lecture: String, + /// Its title. + pub title: String, + /// Where the slides live, when the course records that. + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + /// How many distinct objectives from this lecture were missed. + pub n_objectives: usize, + /// How many questions from this lecture were missed. + pub n_questions: usize, + /// Which questions, so a student can line this up with their paper. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub questions: Vec, + /// The slides those questions came from, when the items record them. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub slides: Vec, + /// The objectives that went wrong here, in the course's own words. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub objectives: Vec, } /// What to read about one objective. @@ -338,6 +456,15 @@ pub fn student( let rows = set.for_student(&summary.student_key); let form = rows.first().and_then(|r| r.form.clone()); + // The summary carries the name; the email only ever existed on the response + // rows, and both are already absent from a pseudonymized store, so neither + // needs a flag here. + let name = summary + .name + .clone() + .or_else(|| rows.iter().find_map(|r| r.name.clone())); + let email = rows.iter().find_map(|r| r.email.clone()); + let levels = summary .levels .iter() @@ -403,6 +530,11 @@ pub fn student( Vec::new() }; + // 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. + let review_lectures = lecture_focus(catalog, &rows, opts); + let study = focus .iter() .take(opts.focus_limit) @@ -417,8 +549,9 @@ pub fn student( StudentDiagnostic { student_key: summary.student_key.clone(), - name: summary.name.clone(), + name, sid: summary.sid.clone(), + email, form, score: Score { points: summary.points, @@ -440,6 +573,7 @@ pub fn student( strengths, focus, questions, + review_lectures, study, } } @@ -457,22 +591,45 @@ fn question_row( opts: &Options, ) -> QuestionRow { let blank = row.selected.is_empty() && row.eliminated.is_empty(); + let missed = row.credit < 0.999; let mut feedback = None; + let mut hint = None; + let mut misconception = None; + let mut worked = None; let mut taught_in = Vec::new(); + let mut review = Vec::new(); if let Some(uid) = row.item_ref.as_deref() { if let Some(entry) = catalog.get(uid) { - if opts.feedback && row.credit < 0.999 { + if missed { if let Some(letter) = row.chosen().first() { - // `student_text` falls back to `explanation`, which is written - // for a grader and often names the right answer. A student - // report must not print it. - feedback = entry.item.option(letter).and_then(|choice| { - choice - .feedback_student - .clone() - .or_else(|| choice.misconception.clone()) - }); + if let Some(choice) = entry.item.option(letter) { + if opts.feedback { + // `student_text` falls back to `explanation`, which is + // written for a grader and often names the right + // answer. A student report must not print it. + feedback = choice + .feedback_student + .clone() + .or_else(|| choice.misconception.clone()); + } + if opts.hints { + hint = choice.hint.clone(); + } + // When an option carries no student feedback, the + // fallback above already printed this text. The same + // sentence twice under two labels reads as a bug. + if opts.misconceptions && choice.misconception != feedback { + misconception = choice.misconception.clone(); + } + } + } + + if let Some(solution) = entry.item.solution.as_ref() { + if opts.solutions { + worked = solution.explanation.clone(); + } + review = item_readings(&catalog.course, &solution.review); } } for source in &entry.item.sources { @@ -503,6 +660,16 @@ fn question_row( position: row.form_position.filter(|p| *p != row.item_number), level: row.level.map(|l| l.code()), objectives: row.learning_objectives.clone(), + objective_texts: if missed { + row.learning_objectives + .iter() + .map(|id| catalog.course.objective_text(id)) + .collect() + } else { + // Only where it earns its space. Every question already carries its + // objective ids, and the objectives table prints all of them. + Vec::new() + }, correct: row.correct, credit: row.credit, bonus: row.bonus, @@ -512,10 +679,167 @@ fn question_row( .then(|| class_rates.get(&row.item_number).copied()) .flatten(), feedback, + hint, + misconception, + worked, taught_in, + review, } } +/// Resolves an item's `review` citations against the course reference registry. +/// +/// # Arguments +/// +/// * `course` - the course, for its reference labels and base URLs. +/// * `citations` - the item's citations. +/// +/// # Returns +/// +/// One entry per citation that resolves to something printable. +fn item_readings(course: &CourseFile, citations: &[Citation]) -> Vec { + let mut out = Vec::new(); + for citation in citations { + let reference = citation + .reference + .as_deref() + .and_then(|key| course.references.get(key).map(|r| (key, r))); + + let (label, title, url) = match reference { + Some((key, reference)) => ( + reference.label.as_deref().unwrap_or(key).to_string(), + Some(reference.title.clone()), + resolve_citation_url(citation, reference), + ), + None => (citation.display(), None, citation.url.clone()), + }; + if label.is_empty() { + continue; + } + + let citation_text = match (&citation.text, &citation.locator) { + (Some(text), _) => text.clone(), + (None, Some(locator)) => format!("{label} {locator}"), + (None, None) => label, + }; + out.push(ItemReading { + citation: citation_text, + title, + url, + }); + } + out +} + +/// A citation's own URL, else the reference's `base_url` joined with its `path`. +fn resolve_citation_url(citation: &Citation, reference: &Reference) -> Option { + if let Some(url) = &citation.url { + return Some(url.clone()); + } + let path = citation.path.as_deref()?; + let base = reference.base_url.as_deref()?; + Some(match (base.ends_with('/'), path.starts_with('/')) { + (true, true) => format!("{base}{}", &path[1..]), + (false, false) => format!("{base}/{path}"), + _ => format!("{base}{path}"), + }) +} + +/// Ranks the lectures behind a student's missed questions. +/// +/// A lecture earns its place by how many distinct objectives went wrong in it, +/// then by how many questions, then by id so the order is stable between runs. +/// +/// # Arguments +/// +/// * `catalog` - the loaded course, for objective and lecture titles. +/// * `rows` - this student's responses. +/// * `opts` - what to include; `questions` decides whether numbers are named. +/// +/// # Returns +/// +/// The lectures, the one that would repay the most time first. +fn lecture_focus(catalog: &Catalog, rows: &[&Response], opts: &Options) -> Vec { + /// What has accumulated for one lecture so far. + #[derive(Default)] + struct Tally { + objectives: BTreeSet, + questions: BTreeSet, + slides: BTreeSet, + } + + let course = &catalog.course; + let mut tallies: BTreeMap = BTreeMap::new(); + + for row in rows.iter().filter(|r| r.counts() && r.credit < 0.999) { + // Two routes to a lecture, and both are wanted. The objective registry + // knows which lectures develop an objective; the item knows which lecture + // it was written from, which is the finer answer when an objective spans + // several. + let mut lectures: BTreeSet = BTreeSet::new(); + let mut slides: BTreeMap> = BTreeMap::new(); + + if let Some(entry) = row.item_ref.as_deref().and_then(|uid| catalog.get(uid)) { + for source in &entry.item.sources { + lectures.insert(source.lecture.clone()); + slides + .entry(source.lecture.clone()) + .or_default() + .extend(source.slides.iter().copied()); + } + } + for objective in &row.learning_objectives { + if let Some(entry) = course.learning_objectives.get(objective) { + lectures.extend(entry.lectures.iter().cloned()); + } + } + + for lecture in lectures { + let tally = tallies.entry(lecture.clone()).or_default(); + tally.objectives.extend(row.learning_objectives.clone()); + tally.questions.insert(row.item_number); + if let Some(numbers) = slides.get(&lecture) { + tally.slides.extend(numbers.iter().copied()); + } + } + } + + let mut out: Vec = tallies + .into_iter() + .map(|(lecture, tally)| { + let record = course.lectures.get(&lecture); + LectureFocus { + title: record + .map(|l| l.title.clone()) + .unwrap_or_else(|| lecture.clone()), + url: record.and_then(|l| l.slides_url.clone()), + n_objectives: tally.objectives.len(), + n_questions: tally.questions.len(), + questions: if opts.questions { + tally.questions.iter().copied().collect() + } else { + Vec::new() + }, + slides: tally.slides.iter().copied().collect(), + objectives: tally + .objectives + .iter() + .map(|id| course.objective_text(id)) + .collect(), + lecture, + } + }) + .collect(); + + out.sort_by(|a, b| { + b.n_objectives + .cmp(&a.n_objectives) + .then(b.n_questions.cmp(&a.n_questions)) + .then(a.lecture.cmp(&b.lecture)) + }); + out +} + /// Resolves an objective to readings. /// /// # Arguments @@ -1139,6 +1463,7 @@ mod tests { student_key: "s-1".into(), name: None, sid: None, + email: None, form: None, score: Score { points: 1.0, @@ -1154,6 +1479,7 @@ mod tests { strengths: Vec::new(), focus: Vec::new(), questions: Vec::new(), + review_lectures: Vec::new(), study: Vec::new(), }) .unwrap(); diff --git a/src/cli.rs b/src/cli.rs index 3c5a32b..a96a667 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -673,6 +673,21 @@ pub(crate) enum ReportCommand { /// Leave per-option feedback out of the Typst report. #[arg(long)] no_feedback: bool, + /// Leave out the hint written for the option the student chose. + #[arg(long)] + no_hints: bool, + /// Name the misconception each chosen distractor was written to catch. + /// + /// Written for you rather than for them, so it reads clinically next to + /// the feedback. Off by default for that reason, not for secrecy. + #[arg(long)] + misconceptions: bool, + /// Include the worked solution for every missed question. + /// + /// This is the solutions document. Reasonable for a question you will + /// not use again, and a way to publish your bank if you reuse it. + #[arg(long)] + solutions: bool, /// Use this template instead of the usual lookup. #[arg(long)] template: Option, diff --git a/src/commands/analysis.rs b/src/commands/analysis.rs index 141f15f..68da7e6 100644 --- a/src/commands/analysis.rs +++ b/src/commands/analysis.rs @@ -497,6 +497,9 @@ pub(crate) fn report(cli: &Cli, sub: &ReportCommand) -> Result { no_markdown, no_questions, no_feedback, + no_hints, + misconceptions, + solutions, template, json, } => { @@ -551,10 +554,24 @@ pub(crate) fn report(cli: &Cli, sub: &ReportCommand) -> Result { comparison: !no_comparison, questions: !no_questions, feedback: !no_feedback, + hints: !no_hints, + misconceptions: *misconceptions, + solutions: *solutions, ability: *ability, ..diagnostic::Options::default() }; + // Said once, at the moment it would matter, rather than left in the + // help text where nobody reads it twice. + if *solutions && !cli.quiet { + println!( + "! --solutions puts the worked solution for every missed question into \ + {} report(s). Reusing these items next term means reusing them against \ + students who may have seen this page", + cohort.students.len() + ); + } + let mut written = 0usize; let mut used_embedded = false; for summary in &cohort.students { diff --git a/src/export/typst/diagnostic.rs b/src/export/typst/diagnostic.rs index 6dc5793..0eba690 100644 --- a/src/export/typst/diagnostic.rs +++ b/src/export/typst/diagnostic.rs @@ -148,6 +148,10 @@ impl Meta { out.insert("assessment", assessment); out.insert("generator", generator); out.insert("policy", policy); + // `students-tested` is the honest name: it is how many people sat this + // assessment, which is not the enrolment. `class-size` stays as an alias + // so a template forked before this change keeps working. + out.insert("students-tested", Value::Int(self.n_students as i64)); out.insert("class-size", Value::Int(self.n_students as i64)); out.insert("extra", extra_value(config)); out @@ -180,6 +184,7 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V out.insert("student-key", Value::str(&diagnostic.student_key)); out.insert_some("name", diagnostic.name.as_ref().map(Value::str)); out.insert_some("sid", diagnostic.sid.as_ref().map(Value::str)); + out.insert_some("email", diagnostic.email.as_ref().map(Value::str)); out.insert_some("form", diagnostic.form.as_ref().map(Value::str)); let mut score = Value::dict(); @@ -296,17 +301,96 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V value.insert("bonus", Value::Bool(question.bonus)); value.insert("blank", Value::Bool(question.blank)); value.insert_some("class-rate", question.class_rate.map(Value::Float)); - value.insert_some( - "feedback", - question - .feedback - .as_ref() - .map(|text| markup_value(text, content)), + value.insert( + "objective-texts", + Value::Array( + question + .objective_texts + .iter() + .map(|text| markup_value(text, content)) + .collect(), + ), ); + for (key, text) in [ + ("feedback", question.feedback.as_ref()), + ("hint", question.hint.as_ref()), + ("misconception", question.misconception.as_ref()), + ("worked", question.worked.as_ref()), + ] { + value.insert_some(key, text.map(|t| markup_value(t, content))); + } value.insert( "taught-in", Value::Array(question.taught_in.iter().map(|s| Value::str(s)).collect()), ); + value.insert( + "review", + Value::Array( + question + .review + .iter() + .map(|reading| { + let mut entry = Value::dict(); + entry.insert("citation", Value::str(&reading.citation)); + entry.insert_some( + "title", + reading.title.as_ref().map(Value::str), + ); + entry.insert_some("url", reading.url.as_ref().map(Value::str)); + entry + }) + .collect(), + ), + ); + value + }) + .collect(), + ), + ); + + out.insert( + "review-lectures", + Value::Array( + diagnostic + .review_lectures + .iter() + .map(|lecture| { + let mut value = Value::dict(); + value.insert("lecture", Value::str(&lecture.lecture)); + value.insert("title", Value::str(&lecture.title)); + value.insert_some("url", lecture.url.as_ref().map(Value::str)); + value.insert("objectives-missed", Value::Int(lecture.n_objectives as i64)); + value.insert("questions-missed", Value::Int(lecture.n_questions as i64)); + value.insert( + "questions", + Value::Array( + lecture + .questions + .iter() + .map(|n| Value::Int(*n as i64)) + .collect(), + ), + ); + value.insert( + "slides", + Value::Array( + lecture + .slides + .iter() + .map(|n| Value::Int(*n as i64)) + .collect(), + ), + ); + value.insert( + "objectives", + Value::Array( + lecture + .objectives + .iter() + .map(|text| markup_value(text, content)) + .collect(), + ), + ); value }) .collect(), diff --git a/src/export/typst/templates/student-report.typ b/src/export/typst/templates/student-report.typ index 0d4d259..237aae4 100644 --- a/src/export/typst/templates/student-report.typ +++ b/src/export/typst/templates/student-report.typ @@ -9,7 +9,7 @@ // // Two markers are in play: // -// // coursebank:begin meta course, assessment, class size, policy +// // coursebank:begin meta course, assessment, how many sat it, policy // // coursebank:end meta // // coursebank:begin data one student's diagnostic // // coursebank:end data @@ -22,6 +22,15 @@ // back before the makeup exam is given, and a report that reproduces the paper // cannot be. If you find yourself wanting to print the question, print its number // and let the student read it off their own copy. +// +// Two fields appear only when you ask for them at the command line, because both +// trade a student's understanding against reusing the question: `misconception` +// (`--misconceptions`) and `worked` (`--solutions`). The blocks that print them +// are below and cost nothing when the fields are absent. +// +// The prose in this file is addressed to a nineteen-year-old reading their own +// result, alone, possibly disappointed. It explains every number before showing +// it. If you change one thing here, keep that. #import "@preview/mitex:0.2.7": mi @@ -35,6 +44,7 @@ assessment: (id: "sample", title: "Sample assessment", date: "2026-01-01"), generator: (tool: "coursebank", version: "0.0.0", on: "2026-01-02"), policy: (mastery-threshold: 0.75, min-items-for-mastery: 2), + students-tested: 24, class-size: 24, extra: (:), ) @@ -44,39 +54,120 @@ #let cb-data = ( student-key: "s-000000000000", name: "Sample Student", + email: "sample@example.edu", form: "A", score: (points: 27.0, possible: 36.0, percent: 75.0, bonus: 1.0, correct: 27, items: 36), standing: (class-mean: 71.2, class-sd: 11.4, band: "upper half"), levels: ( - (level: 1, name: "Remember", blurb: "recalling terms, facts, and definitions", - items: 6, rate: 1.0, class-rate: 0.91, comparison: "above the class"), - (level: 3, name: "Apply", blurb: "using a procedure in a new situation", - items: 9, rate: 0.56, class-rate: 0.64, comparison: "below the class"), + ( + level: 1, + name: "Remember", + blurb: "recalling terms, facts, and definitions", + items: 6, + rate: 1.0, + class-rate: 0.91, + comparison: "above the class", + ), + ( + level: 3, + name: "Apply", + blurb: "using a procedure in a new situation", + items: 9, + rate: 0.56, + class-rate: 0.64, + comparison: "below the class", + ), ), objectives: ( - (id: "lo-sample-met", text: [A sample objective this student met.], items: 3, - credit: 3.0, rate: 1.0, lower: 0.44, upper: 1.0, class-rate: 0.81, - status: "meeting", symbol: "✓", confident: false, thin-evidence: false, levels: (1, 2)), - (id: "lo-sample-gap", text: [A sample objective to work on.], items: 3, - credit: 1.0, rate: 0.33, lower: 0.06, upper: 0.79, class-rate: 0.58, - status: "not yet", symbol: "✗", confident: false, thin-evidence: false, levels: (3,)), + ( + id: "lo-sample-met", + text: [A sample objective this student met.], + items: 3, + credit: 3.0, + rate: 1.0, + lower: 0.44, + upper: 1.0, + class-rate: 0.81, + status: "meeting", + symbol: "✓", + confident: false, + thin-evidence: false, + levels: (1, 2), + ), + ( + id: "lo-sample-gap", + text: [A sample objective to work on.], + items: 3, + credit: 1.0, + rate: 0.33, + lower: 0.06, + upper: 0.79, + class-rate: 0.58, + status: "not yet", + symbol: "✗", + confident: false, + thin-evidence: false, + levels: (3,), + ), ), strengths: ((id: "lo-sample-met", text: [A sample objective this student met.], rate: 1.0, items: 3),), focus: ((id: "lo-sample-gap", text: [A sample objective to work on.], rate: 0.33, items: 3),), questions: ( - (number: 1, level: 1, objectives: ("lo-sample-met",), correct: true, credit: 1.0, - bonus: false, blank: false, class-rate: 0.91, taught-in: ()), - (number: 2, level: 3, objectives: ("lo-sample-gap",), correct: false, credit: 0.0, - bonus: false, blank: false, class-rate: 0.58, - feedback: [This is the note written for the option that was chosen.], - taught-in: ("Enthalpy (L1.1), slides 12, 13",)), + ( + number: 1, + level: 1, + objectives: ("lo-sample-met",), + objective-texts: (), + correct: true, + credit: 1.0, + bonus: false, + blank: false, + class-rate: 0.91, + taught-in: (), + review: (), + ), + ( + number: 2, + level: 3, + objectives: ("lo-sample-gap",), + objective-texts: ([A sample objective to work on.],), + correct: false, + credit: 0.0, + bonus: false, + blank: false, + class-rate: 0.58, + feedback: [This is the note written for the option that was chosen.], + hint: [This is the question you would ask someone reconsidering that option.], + taught-in: ("Enthalpy (L1.1), slides 12, 13",), + review: ((citation: "KKW §6.2", title: "Molecules and Medicine", url: "https://example.edu/6/2"),), + ), + ), + review-lectures: ( + ( + lecture: "L1.1", + title: "Enthalpy", + objectives-missed: 2, + questions-missed: 3, + questions: (2, 14, 15), + slides: (12, 13), + objectives: ([A sample objective to work on.], [A second one from the same lecture.]), + ), ), study: ( - (objective: "lo-sample-gap", text: [A sample objective to work on.], rate: 0.33, - readings: ( - (citation: "KKW §6.2", lecture: "L1.1", lecture-title: "Enthalpy", - focus: [What to take from this section.], supplemental: false), - )), + ( + objective: "lo-sample-gap", + text: [A sample objective to work on.], + rate: 0.33, + readings: ( + ( + citation: "KKW §6.2", + lecture: "L1.1", + lecture-title: "Enthalpy", + focus: [What to take from this section.], + supplemental: false, + ), + ), + ), ), ) // coursebank:end data @@ -91,14 +182,51 @@ #let body-size = eval(extra.at("font-size", default: "10pt")) #let paper = extra.at("paper", default: "us-letter") +// A five-step type scale. Sizes are picked from this list rather than invented at +// the call site, which is what stops a document from drifting into a dozen +// slightly different smalls. +#let size-tag = 0.6em // the level tag inside a question box +#let size-micro = 0.72em // column heads, badges, legends +#let size-meta = 0.9em // provenance: lecture ids, citations, dates +#let size-small = 0.88em // table cells, objective bullets, callouts +#let size-lead = 0.94em // the explanation under each heading +#let size-name = 1.1em // the student's name +#let size-h2 = 1.02em +#let size-h1 = 1.15em +#let size-display = 1.4em // the three numbers at the top +#let size-title = 1.5em + +// One vertical step. Every gap inside a list entry is this or a stated multiple +// of it, and the entries themselves are separated by `entry-gap`. Uneven rhythm +// in a document like this comes from mixing `v()`, linebreaks, and Typst's +// default paragraph spacing in the same block; a stack plus one step avoids all +// three. +#let step = 1.2em +#let entry-gap = 1.0em + +// Prose is held to a readable measure instead of spanning the full text block. +// At 10pt across 17.8cm a line runs to about a hundred characters, which is +// roughly a third too long to track comfortably. Tables and the question grid +// still use the whole width, which is what they are for. +#let prose-pad = 1.5cm + +// The same right edge for prose that already sits in a gutter, so a note body +// lines up with the explanation above it. One centimetre is the 2.8em of number +// column plus gutter at the default body size. +#let prose-pad-inset = prose-pad - 1cm + // Turn sections off from templates/typst.yaml rather than by deleting code, so // a course that does not want the question map keeps the rest of this file. #let show-question-map = extra.at("question-map", default: true) #let show-feedback = extra.at("feedback", default: true) #let show-study = extra.at("study-plan", default: true) #let show-comparison = extra.at("comparison", default: true) +#let show-lecture-plan = extra.at("lecture-plan", default: true) +#let show-item-readings = extra.at("item-readings", default: true) +#let show-intro = extra.at("intro", default: true) #let threshold = cb-meta.policy.at("mastery-threshold", default: 0.75) +#let n-tested = cb-meta.at("students-tested", default: cb-meta.at("class-size", default: 0)) #let ok-color = rgb("#2a9d8f") #let mid-color = rgb("#D19F1F") @@ -108,23 +236,28 @@ #set page( paper: paper, margin: (x: 1.9cm, y: 2.1cm), - header: text(size: 0.8em, fill: luma(110))[ + header: text(size: size-meta, fill: luma(110))[ #cb-meta.course.code · #cb-meta.assessment.title · individual diagnostic ], - footer: context text(size: 0.8em, fill: luma(110))[ + footer: context text(size: size-meta, fill: luma(110))[ #h(1fr) Page #counter(page).display("1 of 1", both: true) ], ) #set text(font: body-font, size: body-size, lang: "en") #set par(justify: false, leading: 0.62em) -#show heading.where(level: 1): it => block(above: 1.4em, below: 0.7em)[ - #text(size: 1.15em, weight: "bold", fill: accent)[#it.body] + +// Figures in the tables are columns of numbers, so they get tabular widths and +// line up under one another. +#show table: set text(number-width: "tabular") + +#show heading.where(level: 1): it => block(above: 1.5em, below: entry-gap)[ + #text(size: size-h1, weight: "bold", fill: accent)[#it.body] #v(-0.45em) #line(length: 100%, stroke: 0.6pt + accent.lighten(55%)) ] #show heading.where(level: 2): it => block(above: 1em, below: 0.45em)[ - #text(size: 1.02em, weight: "bold")[#it.body] + #text(size: size-h2, weight: "bold")[#it.body] ] // ───────────────────────────────────────────────────────────────────────────── @@ -138,24 +271,50 @@ #let pct(rate) = str(calc.round(rate * 100)) + "%" #let pct1(value) = str(calc.round(value, digits: 0)) + "%" +#let plural(n, one, many) = if n == 1 { one } else { many } + #let status-color(status) = { - if status == "meeting" { ok-color } - else if status == "developing" { mid-color } - else if status == "not yet" { bad-color } - else { thin-color } + if status == "meeting" { ok-color } else if status == "developing" { mid-color } else if status == "not yet" { + bad-color + } else { thin-color } } #let rate-color(rate) = { - if rate >= threshold { ok-color } - else if rate >= threshold * 0.6 { mid-color } - else { bad-color } + if rate >= threshold { ok-color } else if rate >= threshold * 0.6 { mid-color } else { bad-color } } #let badge(label, color) = box( fill: color.lighten(82%), radius: 3pt, inset: (x: 5pt, y: 2.5pt), -)[#text(size: 0.72em, weight: "bold", fill: color.darken(12%))[#label]] +)[#text(size: size-micro, weight: "bold", fill: color.darken(12%))[#label]] + +// A column head. One definition rather than the same three arguments repeated at +// every header cell, so the heads cannot drift apart. +#let th(body) = text(size: size-micro, fill: luma(95), tracking: 0.04em)[#body] + +// A numbered disc for a ranked list. Fixed width, so the text of every entry +// starts at the same place no matter whether the rank is 1 or 11. +#let rank(n, color) = box( + width: 1.35em, + height: 1.35em, + radius: 50%, + fill: color, +)[#align(center + horizon)[#text(size: size-micro, weight: "bold", fill: white)[#str(n)]]] + +// An aside with a rule down its left edge: the action to take, set apart from the +// explanation above it without another box or another tint. +#let callout(name, body) = block( + inset: (left: 0.65em), + stroke: (left: 1.5pt + accent.lighten(55%)), +)[ + #text(size: size-small)[#text(weight: "bold", fill: luma(75))[#name:] #body] +] + +// A provenance line: where something was taught, or what to read. +#let meta-pair(name, body) = text(size: size-meta, fill: luma(115))[ + #text(weight: "bold")[#name:] #body +] // Two boxes side by side rather than an overlay: no `place`, no coordinate // arithmetic, and it degrades to something sensible at any width. @@ -170,48 +329,86 @@ ] } -#let stat-card(label, value, note: none) = block( - width: 100%, - fill: luma(247), - radius: 4pt, - inset: (x: 10pt, y: 9pt), -)[ - #text(size: 0.75em, fill: luma(95))[#upper(label)] - #v(0.15em) - #text(size: 1.45em, weight: "bold", fill: accent)[#value] - #if note != none [ - #v(0.1em) - #text(size: 0.78em, fill: luma(95))[#note] - ] +// The three cards at the top. The label sits above the number and the gloss +// below it, so the eye lands on the figure and can then read outwards. +#let stat-card(label, value, note: none) = { + let parts = ( + text(size: size-micro, fill: luma(95), tracking: 0.06em)[#upper(label)], + text(size: size-display, weight: "bold", fill: accent)[#value], + ) + if note != none { + parts.push(text(size: size-meta, fill: luma(95))[#note]) + } + block( + height: 9em, + width: 100%, + fill: luma(247), + radius: 4pt, + inset: (x: 10pt, y: 9pt), + )[#stack(dir: ttb, spacing: step * 1.0, ..parts)] +} + +// The explanation under a heading. Every section has one, because a number a +// student cannot interpret is worse than no number at all. +#let explain(body) = block(below: entry-gap)[ + #pad(right: prose-pad)[#text(size: size-lead, fill: luma(95))[#body]] ] // ───────────────────────────────────────────────────────────────────────────── // Heading // ───────────────────────────────────────────────────────────────────────────── -#block(below: 0.6em)[ - #text(size: 1.5em, weight: "bold")[#cb-meta.assessment.title] +#block(below: 0.35em)[ + #text(size: size-title, weight: "bold")[#cb-meta.assessment.title] #h(0.6em) - #text(size: 1em, fill: luma(110))[ - #cb-meta.course.code — #cb-meta.course.title + #text(fill: luma(110))[ + #cb-meta.course.code · #cb-meta.course.title ] ] -#block(below: 1.2em)[ - #text(size: 0.95em)[ - *#cb-data.at("name", default: cb-data.student-key)* - #{ - let sid = cb-data.at("sid", default: none) - if sid != none [ · #sid ] - } - #{ +#let student-name = cb-data.at("name", default: none) +#let student-email = cb-data.at("email", default: none) +#let student-sid = cb-data.at("sid", default: none) + +#block(below: entry-gap)[ + #stack( + dir: ttb, + spacing: step * 0.5, + text(size: size-name, weight: "bold")[ + #if student-name != none { student-name } else { cb-data.student-key } + ], + { + // Identity on its own line, and only what the export actually carried. A + // label with nothing after it reads like a mistake. + let parts = () + if student-email != none { parts.push(student-email) } + if student-sid != none { parts.push(student-sid) } let form = cb-data.at("form", default: none) - if form != none [ · Form #form ] - } - #{ + if form != none { parts.push("Form " + form) } let date = cb-meta.assessment.at("date", default: none) - if date != none [ · #date ] - } + if date != none { parts.push(date) } + if parts.len() > 0 { + text(size: size-meta, fill: luma(110))[#parts.join(" · ")] + } + }, + ) +] + +#if show-intro [ + #block( + width: 100%, + fill: accent.lighten(95%), + radius: 4pt, + inset: (x: 11pt, y: 10pt), + below: 1.2em, + )[ + #pad(right: prose-pad - 1.1cm)[ + #text(size: size-lead)[ + This map explains what the exam covered and what your results mean, so you can use the information. Your score appears first because that's what you probably want to see. After that, you'll find more helpful details: which types of questions you did well on, which topics you might want to review, and what to read for each area. The last two sections are the most practical, so if you only read part of this, focus on those. + + This report does not judge your abilities, and one question alone does not say much. If a score is based on just one or two questions, the report will point that out so you don't read too much into it. + ] + ] ] ] @@ -226,24 +423,31 @@ columns: (1fr, 1fr, 1fr), gutter: 10pt, stat-card( - "score", + "your score", pct1(score.percent), - note: str(score.points) + " of " + str(score.possible) + " points" + note: str(score.points) + + " of " + + str(score.possible) + + " points" + (if score.at("bonus", default: 0.0) > 0.0 { " (+" + str(score.bonus) + " bonus)" } else { "" }), ), stat-card( - "questions", + "questions right", str(score.correct) + " / " + str(score.items), - note: "answered correctly", + note: "out of " + str(score.items) + " " + plural(score.items, "question", "questions"), ), if standing != none and show-comparison { stat-card( - "class", + "class average", pct1(standing.class-mean), - note: "class average · you are in the " + standing.band, + note: "across the " + str(n-tested) + " students who took this exam · you are in the " + standing.band, ) } else { - stat-card("class size", str(cb-meta.at("class-size", default: 0)), note: "students") + stat-card( + "students tested", + str(n-tested), + note: "took this exam", + ) }, ) @@ -254,39 +458,36 @@ #if cb-data.levels.len() > 0 [ = How you did, by kind of thinking - #text(size: 0.9em, fill: luma(95))[ - Questions are written at levels. Scoring well on recall and less well on - application is a different problem from scoring evenly and low, and it has a - different fix. + #explain[ + Each question on this exam was designed to test a specific type of thinking, from recalling definitions to analyzing situations. The table below breaks down your results by these types. The number in brackets shows how many questions of each type you answered. 'You' shows the percentage you got right, and 'class' shows the average for everyone who took the exam. + + Focus on the overall pattern, not just the specific numbers. If you did well on recall but struggled with applied questions, try practicing more problems. This is different from just reviewing the material. If your results are similar across all levels, it may be helpful to review the material itself. ] - #v(0.5em) - + // The figure comes before its bar in both tables on this page, so the numbers + // read as a column and the bars all start from the same left edge. #table( - columns: (auto, 1fr, auto, auto, auto), + columns: (auto, 1fr, auto, 2.7cm, auto), stroke: none, - align: (left + horizon, left + horizon, left + horizon, right + horizon, left + horizon), - inset: (x: 5pt, y: 5pt), + align: (left + horizon, left + horizon, right + horizon, left + horizon, right + horizon), + inset: (x: 5pt, y: 6pt), fill: (_, row) => if calc.odd(row) { luma(250) } else { white }, - table.header( - text(size: 0.78em, fill: luma(95))[LEVEL], - text(size: 0.78em, fill: luma(95))[WHAT IT ASKS], - text(size: 0.78em, fill: luma(95))[YOU], - text(size: 0.78em, fill: luma(95))[], - text(size: 0.78em, fill: luma(95))[CLASS], - ), - ..cb-data.levels.map(level => ( - [*#level.name* #text(size: 0.8em, fill: luma(120))[(#level.items)]], - text(size: 0.85em, fill: luma(80))[#level.blurb], - bar(level.rate, color: rate-color(level.rate), width: 2.6cm), - [#pct(level.rate)], - { - let class-rate = level.at("class-rate", default: none) - if class-rate != none and show-comparison { - text(size: 0.85em, fill: luma(100))[#pct(class-rate)] - } else { [] } - }, - )).flatten(), + table.header(th[LEVEL], th[WHAT IT ASKS FOR], th[YOU], th[], th[CLASS]), + ..cb-data + .levels + .map(level => ( + [*#level.name* #text(size: size-meta, fill: luma(120))[(#level.items)]], + text(size: size-small, fill: luma(80))[#level.blurb], + text(weight: "medium")[#pct(level.rate)], + bar(level.rate, color: rate-color(level.rate), width: 2.4cm), + { + let class-rate = level.at("class-rate", default: none) + if class-rate != none and show-comparison { + text(size: size-small, fill: luma(100))[#pct(class-rate)] + } else { [] } + }, + )) + .flatten(), ) ] @@ -295,62 +496,52 @@ // ───────────────────────────────────────────────────────────────────────────── #if cb-data.objectives.len() > 0 [ - = What you have learned, objective by objective + = What the exam measured, objective by objective - #text(size: 0.9em, fill: luma(95))[ - Each line is one thing the course asked you to be able to do, and how many - questions measured it. Two or three questions is thin evidence, so treat a - single line as a hint rather than a verdict; the pattern across lines is what - to trust. + #explain[ + Each line shows something the course asked you to do, just as it appears in the syllabus. *Q* tells you how many questions measured that skill. *You* shows the share you got right, and *class* shows the same for everyone else. + + The symbol in the first column is a summary. The mark you should focus on is #text(fill: thin-color, weight: "bold")[?], which means there were not enough questions to draw any conclusions about that line. On exams with many objectives, most lines will have this mark, since one question cannot show if you really know something or just guessed. These lines are not good or bad news. Instead, look for groups of lines that point in the same direction, and check the next two sections, which organize them for you. ] - #v(0.5em) - + // The objective text is the only thing in this table that wants width, so it + // takes the free column and everything else is sized to its content. The + // thin-evidence badge that used to sit inline is gone: it repeated on every + // row of a three-page table, wrapped the text, and said no more than the `?` + // in the first column already says. #table( - columns: (auto, 1fr, auto, auto, auto), + columns: (auto, 1fr, auto, auto, 2.4cm, auto), stroke: none, - align: (center + horizon, left + horizon, center + horizon, left + horizon, right + horizon), - inset: (x: 5pt, y: 5pt), + align: (center + horizon, left + horizon, right + horizon, right + horizon, left + horizon, right + horizon), + inset: (x: 5pt, y: 6pt), fill: (_, row) => if calc.odd(row) { luma(250) } else { white }, - table.header( - text(size: 0.78em, fill: luma(95))[], - text(size: 0.78em, fill: luma(95))[OBJECTIVE], - text(size: 0.78em, fill: luma(95))[Q], - text(size: 0.78em, fill: luma(95))[YOU], - text(size: 0.78em, fill: luma(95))[CLASS], - ), - ..cb-data.objectives.map(objective => ( - text(fill: status-color(objective.status), weight: "bold")[#objective.symbol], - { - markup(objective.text) - if objective.at("thin-evidence", default: false) { - [ #badge("too few questions to say", thin-color)] - } - }, - text(size: 0.85em, fill: luma(110))[#objective.items], - { - stack( - dir: ltr, - spacing: 5pt, - bar(objective.rate, color: status-color(objective.status), width: 2.1cm), - text(size: 0.9em)[#pct(objective.rate)], - ) - }, - { - let class-rate = objective.at("class-rate", default: none) - if class-rate != none and show-comparison { - text(size: 0.85em, fill: luma(100))[#pct(class-rate)] - } else { [] } - }, - )).flatten(), + table.header(th[], th[OBJECTIVE], th[Q], th[YOU], th[], th[CLASS]), + ..cb-data + .objectives + .map(objective => ( + text(fill: status-color(objective.status), weight: "bold")[#objective.symbol], + text(size: size-small)[#markup(objective.text)], + text(size: size-small, fill: luma(110))[#objective.items], + text(size: size-small, weight: "medium")[#pct(objective.rate)], + bar(objective.rate, color: status-color(objective.status), width: 2.1cm), + { + let class-rate = objective.at("class-rate", default: none) + if class-rate != none and show-comparison { + text(size: size-small, fill: luma(100))[#pct(class-rate)] + } else { [] } + }, + )) + .flatten(), ) - #v(0.4em) - #text(size: 0.78em, fill: luma(110))[ - #text(fill: ok-color, weight: "bold")[✓] meeting · #text(fill: mid-color, weight: "bold")[~] - developing · #text(fill: bad-color, weight: "bold")[✗] not yet · - #text(fill: thin-color, weight: "bold")[?] not enough evidence. The line is - drawn at #pct(threshold). + #v(0.5em) + #text(size: size-micro, fill: luma(110))[ + #text(fill: ok-color, weight: "bold")[✓] you have this · + #text(fill: mid-color, weight: "bold")[~] getting there · + #text(fill: bad-color, weight: "bold")[✗] not yet · + #text(fill: thin-color, weight: "bold")[?] too few questions to say, which is + every line measured by one question. + A line counts as solid at #pct(threshold) or better. ] ] @@ -358,47 +549,154 @@ // Strengths and focus // ───────────────────────────────────────────────────────────────────────────── +// The two panels are the same object twice, so they are one function. `rows` is +// a list of (text, trailing) pairs; the trailing part is the rate, which only +// the focus panel carries. +#let panel(title, color, note, rows) = block( + width: 100%, + fill: color.lighten(93%), + radius: 4pt, + inset: 10pt, +)[ + #stack( + dir: ttb, + spacing: step, + text(weight: "bold", fill: color.darken(15%))[#title], + text(size: size-small, fill: luma(95))[#note], + { + set text(size: size-small) + list( + indent: 0pt, + body-indent: 0.45em, + spacing: step * 0.7, + marker: text(fill: color.darken(5%))[·], + ..rows, + ) + }, + ) +] +// ───────────────────────────────────────────────────────────────────────────── + #let strengths = cb-data.at("strengths", default: ()) #let focus = cb-data.at("focus", default: ()) #if strengths.len() > 0 or focus.len() > 0 [ - = Where to put your time + = Where your time will go furthest + + #explain[ + These two lists are based on the table above. I left out the single-question lines, so what is left has enough evidence to support action. + ] #grid( columns: (1fr, 1fr), gutter: 12pt, if focus.len() > 0 { - block( - width: 100%, - fill: bad-color.lighten(93%), - radius: 4pt, - inset: 10pt, - )[ - #text(weight: "bold", fill: bad-color.darken(15%))[Work on these first] - #v(0.35em) - #for objective in focus [ - - #markup(objective.text) - #text(size: 0.8em, fill: luma(110))[ (#pct(objective.rate) of #objective.items)] - ] - ] + panel( + "Start here", + bad-color, + [Start by reviewing the material that is likely to change the most and is the hardest for you.], + focus.map(objective => [ + #markup(objective.text) + #text(size: size-meta, fill: luma(110))[(#pct(objective.rate) of #objective.items)] + ]), + ) } else { [] }, if strengths.len() > 0 { - block( - width: 100%, - fill: ok-color.lighten(93%), - radius: 4pt, - inset: 10pt, - )[ - #text(weight: "bold", fill: ok-color.darken(18%))[Solid, keep it] - #v(0.35em) - #for objective in strengths [ - - #markup(objective.text) - ] - ] + panel( + "Already solid", + ok-color, + [You showed these. Spend your review time elsewhere.], + strengths.map(objective => markup(objective.text)), + ) } else { [] }, ) ] +// ───────────────────────────────────────────────────────────────────────────── +// Which lecture to go back to +// ───────────────────────────────────────────────────────────────────────────── + +// One ranked lecture. Three fixed columns: the rank, the body, the count. The +// body is a stack, so the title, the provenance line, and the objectives are one +// step apart and no paragraph contributes spacing of its own. +#let lecture-entry(index, lecture) = { + let count = lecture.at("objectives-missed", default: 0) + let numbers = lecture.at("questions", default: ()) + let slides = lecture.at("slides", default: ()) + let objectives = lecture.at("objectives", default: ()) + + let parts = () + + parts.push({ + let url = lecture.at("url", default: none) + let title = text(weight: "bold")[#lecture.title] + [ + #(if url != none { link(url)[#title] } else { title }) + #text(size: size-meta, fill: luma(115))[(#lecture.lecture)] + ] + }) + + // Slides and question numbers were two separate lines, one of them reached by + // a linebreak and one by a block. Together on one line they are easier to skim + // and the entry loses a ragged gap. + let trail = () + if slides.len() > 0 { + trail.push(plural(slides.len(), "slide ", "slides ") + slides.map(str).join(", ")) + } + if numbers.len() > 0 { + trail.push( + "you missed " + plural(numbers.len(), "question ", "questions ") + numbers.map(str).join(", "), + ) + } + if trail.len() > 0 { + parts.push(text(size: size-meta, fill: luma(115))[#trail.join(" · ")]) + } + + // A real list rather than a middot glued to the front of a paragraph, so the + // second line of a long objective indents under the first instead of running + // back to the margin. + if objectives.len() > 0 { + parts.push({ + set text(size: size-small, fill: luma(80)) + list( + indent: 0pt, + body-indent: 0.45em, + spacing: step * 0.7, + marker: text(fill: luma(165))[·], + ..objectives.map(objective => markup(objective)), + ) + }) + } + + block(breakable: false, above: entry-gap, width: 100%)[ + #grid( + columns: (1.35em, 1fr, 5.2em), + column-gutter: 0.7em, + align: (left + top, left + top, right + top), + rank(index + 1, if index == 0 { bad-color } else if count > 1 { mid-color } else { accent }), + pad(right: prose-pad-inset)[#stack(dir: ttb, spacing: step, ..parts)], + text(size: size-micro, fill: luma(105))[ + #count #plural(count, "objective", "objectives") + ], + ) + ] +} +// ───────────────────────────────────────────────────────────────────────────── + +#let review-lectures = cb-data.at("review-lectures", default: ()) + +#if show-lecture-plan and review-lectures.len() > 0 [ + = Which lectures to go back to + + #explain[ + Each question connects to the lecture it came from. If you sort the lectures by how many different objectives were missed, you get a clear order to review, starting with the most challenging. When one lecture covers several objectives, it often means an early idea was unclear, and fixing that is usually the easiest way to help. + ] + + #for (index, lecture) in review-lectures.enumerate() [ + #lecture-entry(index, lecture) + ] +] + // ───────────────────────────────────────────────────────────────────────────── // Study plan // ───────────────────────────────────────────────────────────────────────────── @@ -408,34 +706,36 @@ #if show-study and study.len() > 0 [ = What to read - #text(size: 0.9em, fill: luma(95))[ - These are the sections behind the objectives above, taken from the course - reading list rather than chosen generically. + #explain[ + Here are the sections from the course reading list that match the objectives above. Under each section, you'll find a brief sentence on what to focus on, so you don't need to reread the entire section. ] - #v(0.4em) - #for group in study [ - #block(breakable: false, above: 0.8em)[ + #block(breakable: false, above: entry-gap)[ == #markup(group.text) #for reading in group.readings [ - #block(inset: (left: 0.8em), above: 0.35em)[ + #block(inset: (left: 0.8em), above: step)[ #{ let url = reading.at("url", default: none) let cite = text(weight: "bold")[#reading.citation] - if url != none { link(url)[#cite] } else { cite } - } - #text(size: 0.85em, fill: luma(110))[ - — #reading.lecture-title (#reading.lecture)#{ - if reading.at("supplemental", default: false) { ", supplemental" } - } - ] - #{ + let parts = ( + [ + #(if url != none { link(url)[#cite] } else { cite }) + #text(size: size-meta, fill: luma(110))[ + · #reading.lecture-title (#reading.lecture)#{ + if reading.at("supplemental", default: false) { ", optional" } + } + ] + ], + ) let focus-note = reading.at("focus", default: none) - if focus-note != none [ - \ #text(size: 0.9em)[#markup(focus-note)] - ] + if focus-note != none { + parts.push(pad(right: prose-pad-inset)[ + #text(size: size-small)[#markup(focus-note)] + ]) + } + stack(dir: ttb, spacing: step * 0.6, ..parts) } ] ] @@ -450,10 +750,9 @@ #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 { ok-color } - else if q.at("credit", default: 0.0) > 0.0 { mid-color } - else { bad-color } + let color = 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( width: 100%, fill: color.lighten(85%), @@ -462,12 +761,12 @@ inset: (x: 2pt, y: 4pt), )[ #align(center)[ - #text(size: 0.85em, 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 level != none { linebreak() - text(size: 0.62em, fill: luma(110))[L#level] + text(size: size-tag, fill: luma(120))[L#level] } } ] @@ -475,27 +774,27 @@ } #if show-question-map and questions.len() > 0 [ - = Question by question + #block(breakable: false)[ + = Question by question - #text(size: 0.9em, fill: luma(95))[ - Numbers refer to your own copy of the exam. The questions themselves are not - reproduced here. - ] + #explain[ + There is one box for each question, in the same order as on your exam paper. Each box is colored to show your result. The small *L* below each box shows the type of thinking required, matching the first table. The questions are not shown here, so use these numbers during office hours. + ] - #v(0.5em) + #grid( + columns: (1fr,) * 10, + column-gutter: 4pt, + row-gutter: 5pt, + ..questions.map(question-box), + ) - #grid( - columns: (1fr,) * 10, - gutter: 4pt, - ..questions.map(question-box), - ) - - #v(0.5em) - #text(size: 0.78em, fill: luma(110))[ - #box(width: 0.7em, height: 0.7em, fill: ok-color.lighten(70%), radius: 2pt) correct · - #box(width: 0.7em, height: 0.7em, fill: mid-color.lighten(70%), radius: 2pt) partial · - #box(width: 0.7em, height: 0.7em, fill: bad-color.lighten(70%), radius: 2pt) incorrect · - #box(width: 0.7em, height: 0.7em, fill: luma(210), radius: 2pt) blank + #v(0.6em) + #text(size: size-micro, fill: luma(110))[ + #box(width: 0.7em, height: 0.7em, fill: ok-color.lighten(70%), radius: 2pt) right · + #box(width: 0.7em, height: 0.7em, fill: mid-color.lighten(70%), radius: 2pt) part marks · + #box(width: 0.7em, height: 0.7em, fill: bad-color.lighten(70%), radius: 2pt) not right · + #box(width: 0.7em, height: 0.7em, fill: luma(210), radius: 2pt) left blank + ] ] ] @@ -503,37 +802,103 @@ // Notes on what was missed // ───────────────────────────────────────────────────────────────────────────── -#let missed = questions.filter(q => - q.at("credit", default: 0.0) < 0.999 and q.at("feedback", default: none) != none -) +// One note. Four layers, in the order a student needs them: what the question +// was measuring, what went wrong, what to ask next time, and where to look it +// up. Each is a stack child, so every gap is one step and the provenance lines +// at the end sit tight together as a single footer. +#let note-entry(q) = { + let parts = () + + let texts = q.at("objective-texts", default: ()) + if texts.len() > 0 { + parts.push(text(fill: luma(21.57%))[ + #text(weight: "bold")[Measuring:] #texts.map(t => markup(t)).join([; ]) + ]) + } + + // The diagnosis carries full body size. It is the sentence worth reading + // twice, and it was previously the same weight as the objective above it. + let feedback = q.at("feedback", default: none) + if feedback != none { parts.push(markup(feedback)) } + + let hint = q.at("hint", default: none) + if hint != none { parts.push(callout("Try this", markup(hint))) } + + // Present only with `--misconceptions`. This one is written to the instructor + // about the answer, which is why it reads differently. + let misconception = q.at("misconception", default: none) + if misconception != none { + parts.push(callout("The idea this option tests for", markup(misconception))) + } + + // Present only with `--solutions`. + let worked = q.at("worked", default: none) + if worked != none { + parts.push(block( + width: 100%, + fill: luma(249), + radius: 3pt, + inset: (x: 8pt, y: 7pt), + )[ + #stack( + dir: ttb, + spacing: step * 0.5, + text(size: size-meta, weight: "bold", fill: luma(85))[HOW IT WORKS OUT], + text(size: size-small)[#markup(worked)], + ) + ]) + } + + let trail = () + let taught = q.at("taught-in", default: ()) + if taught.len() > 0 { trail.push(meta-pair("Taught in", taught.join("; "))) } + + let review = q.at("review", default: ()) + if show-item-readings and review.len() > 0 { + let cites = review.map(reading => { + let url = reading.at("url", default: none) + let cite = reading.citation + if url != none { link(url)[#cite] } else { [#cite] } + }) + trail.push(meta-pair("Read again", cites.join([ · ]))) + } + if trail.len() > 0 { + parts.push(stack(dir: ttb, spacing: step * 0.4, ..trail)) + } + + block(breakable: false, above: entry-gap, width: 100%)[ + #grid( + columns: (1.8em, 1fr), + column-gutter: 0.7em, + align: (right + top, left + top), + text(weight: "bold", fill: accent)[#str(q.number)], + pad(right: prose-pad-inset)[#stack(dir: ttb, spacing: step, ..parts)], + ) + ] +} +// ───────────────────────────────────────────────────────────────────────────── + +#let missed = questions.filter(q => ( + q.at("credit", default: 0.0) < 0.999 + and ( + q.at("feedback", default: none) != none + or q.at("hint", default: none) != none + or q.at("worked", default: none) != none + or q.at("review", default: ()).len() > 0 + ) +)) #if show-feedback and missed.len() > 0 [ - = Notes on the ones you missed + = A closer look at the ones you missed - #text(size: 0.9em, fill: luma(95))[ - Each note is written for the specific option you chose, so it names the idea - behind that answer rather than restating the right one. + #explain[ + Each note below explains the reasoning behind your answer instead of just giving the correct one. It helps you see where things went off track so you can avoid the same mistake next time. You can also use your paper to compare your answer with the question. + + If you see a line starting with *try this*, it suggests a question to ask yourself as you review the problem. If a reading is listed, it shows which section it came from. ] - #v(0.5em) - #for q in missed [ - #block(breakable: false, above: 0.7em, width: 100%)[ - #grid( - columns: (2.6em, 1fr), - gutter: 6pt, - align(top)[#text(weight: "bold", fill: accent)[#q.number.]], - [ - #markup(q.feedback) - #{ - let taught = q.at("taught-in", default: ()) - if taught.len() > 0 [ - \ #text(size: 0.82em, fill: luma(110))[Covered in: #taught.join("; ")] - ] - } - ], - ) - ] + #note-entry(q) ] ] @@ -541,12 +906,9 @@ // Footer note // ───────────────────────────────────────────────────────────────────────────── -#v(1.2em) +#v(1.3em) #line(length: 100%, stroke: 0.5pt + luma(210)) -#v(0.4em) -#text(size: 0.76em, fill: luma(120))[ - Generated #cb-meta.generator.on by #cb-meta.generator.tool #cb-meta.generator.version - from #cb-meta.at("class-size", default: 0) student(s). Percentages on a handful of - questions carry wide uncertainty; bring this to office hours rather than reading - it as a verdict. -] +#v(0.45em) +#pad(right: prose-pad)[#text(size: size-micro, fill: luma(120))[ + Built on #cb-meta.generator.on by #cb-meta.generator.tool #cb-meta.generator.version, from the #str(n-tested) #plural(n-tested, "student", "students") who took this exam. Because a percentage based on one or two questions can be highly uncertain, bring anything here that looks wrong or surprising to office hours. That is the best use you can make of this page. +]]