fix: exam report targets and objectives
Pipeline / check (pull_request) Successful in 2m43s
Pipeline / docs (pull_request) Skipped
Pipeline / nightly (pull_request) Skipped
Pipeline / release (pull_request) Skipped

This commit is contained in:
2026-09-22 00:39:22 -04:00
parent cfe8a3216c
commit d3f21e913b
5 changed files with 233 additions and 131 deletions
+97 -46
View File
@@ -76,7 +76,7 @@ use crate::irt::Fit;
use crate::item::Citation; use crate::item::Citation;
use crate::responses::{Response, ResponseSet}; use crate::responses::{Response, ResponseSet};
use crate::students::{Cohort, Mastery, StudentSummary}; use crate::students::{Cohort, Mastery, StudentSummary};
use crate::taxonomy::Level; use crate::taxonomy::{Level, Tier};
/// What to assemble. /// What to assemble.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -164,6 +164,15 @@ pub struct StudentDiagnostic {
/// Per-level performance. /// Per-level performance.
pub levels: Vec<LevelRow>, pub levels: Vec<LevelRow>,
/// Per-objective standing, in the course's own order. /// Per-objective standing, in the course's own order.
///
/// Objectives only, never their targets. This is the table that makes a
/// claim, and a claim needs a denominator: an objective's row aggregates
/// every item tagged to any of its targets, while a target's row usually
/// rests on one question and could only ever read "not enough questions to
/// say". Mixing the two produced a three-page table where most rows carried
/// that mark and the few real classifications were lost among them. The
/// specifics live in the two sections built for them: which lectures to go
/// back to, and the notes on missed questions.
pub objectives: Vec<ObjectiveRow>, pub objectives: Vec<ObjectiveRow>,
/// Objectives they are clearly meeting, worst first among the confident ones. /// Objectives they are clearly meeting, worst first among the confident ones.
pub strengths: Vec<ObjectiveRef>, pub strengths: Vec<ObjectiveRef>,
@@ -299,6 +308,20 @@ pub struct ObjectiveRef {
pub n_items: usize, pub n_items: usize,
} }
/// What one question measured, named at both tiers.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct Measured {
/// The objective this question's result rolls up to.
///
/// `None` when the tagged id is an objective with no targets of its own, so
/// that a report does not print the same sentence twice.
#[serde(skip_serializing_if = "Option::is_none")]
pub objective: Option<String>,
/// The target the question was written against.
pub target: String,
}
/// One question, described without being reproduced. /// One question, described without being reproduced.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
@@ -311,15 +334,19 @@ pub struct QuestionRow {
/// The level code. /// The level code.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub level: Option<u8>, pub level: Option<u8>,
/// The objectives it measured. /// The learning targets it measured, by id.
pub objectives: Vec<String>, pub targets: Vec<String>,
/// What those objectives ask, in the words the course uses with students. /// What this question measured, at both tiers.
/// ///
/// The objective, not the question. It is printed in full in the objectives /// Both, because each answers a different question a student has in front of
/// table already; repeating it next to a missed question saves a student /// a missed item. The target says what this question actually asked of them,
/// working out which of thirty-six rows this one belonged to. /// which is the specific thing to go and practise. The objective says which
/// row of the table above the mark landed in, which is how they tell whether
/// one slip cost them a claim or whether it was one of several. Printing the
/// target alone left them unable to connect the note to the table; printing
/// the objective alone described something broader than the question.
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub objective_texts: Vec<String>, pub measured: Vec<Measured>,
/// Whether it was answered correctly. /// Whether it was answered correctly.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub correct: Option<bool>, pub correct: Option<bool>,
@@ -403,8 +430,14 @@ pub struct LectureFocus {
/// Where the slides live, when the course records that. /// Where the slides live, when the course records that.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>, pub url: Option<String>,
/// How many distinct objectives from this lecture were missed. /// How many distinct learning targets from this lecture were missed. The
pub n_objectives: usize, /// ranking key.
///
/// Counting targets rather than objectives keeps the ranking informative: a
/// lecture where four separate performances went wrong needs more time than
/// one where a single performance was missed twice, and counting objectives
/// would score those the same.
pub n_targets: usize,
/// How many questions from this lecture were missed. /// How many questions from this lecture were missed.
pub n_questions: usize, pub n_questions: usize,
/// Which questions, so a student can line this up with their paper. /// Which questions, so a student can line this up with their paper.
@@ -413,9 +446,14 @@ pub struct LectureFocus {
/// The slides those questions came from, when the items record them. /// The slides those questions came from, when the items record them.
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub slides: Vec<u32>, pub slides: Vec<u32>,
/// The objectives that went wrong here, in the course's own words. /// The learning targets that went wrong here, in the course's own words.
///
/// Targets rather than objectives, because this section answers "what do I
/// go and restudy". "You missed the objective on binding" sends a student to
/// a whole lecture; "you missed reading a dissociation constant off an
/// isotherm" sends them to one page of it.
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub objectives: Vec<String>, pub targets: Vec<String>,
} }
/// What to read about one objective. /// What to read about one objective.
@@ -508,6 +546,8 @@ pub fn student(
let objectives: Vec<ObjectiveRow> = summary let objectives: Vec<ObjectiveRow> = summary
.objectives .objectives
.iter() .iter()
// Objectives only; see `StudentDiagnostic::objectives`.
.filter(|mastery| mastery.tier == Tier::Objective)
.map(|mastery| ObjectiveRow { .map(|mastery| ObjectiveRow {
id: mastery.id.clone(), id: mastery.id.clone(),
text: mastery.text.clone(), text: mastery.text.clone(),
@@ -697,15 +737,26 @@ fn question_row(
number: row.item_number, number: row.item_number,
position: row.form_position.filter(|p| *p != row.item_number), position: row.form_position.filter(|p| *p != row.item_number),
level: row.level.map(|l| l.code()), level: row.level.map(|l| l.code()),
objectives: row.learning_targets.clone(), targets: row.learning_targets.clone(),
objective_texts: if missed { measured: if missed {
let course = &catalog.course;
row.learning_targets row.learning_targets
.iter() .iter()
.map(|id| catalog.course.text_for(id)) .map(|id| {
let objective = course.objective_for(id);
Measured {
// An objective with no targets of its own is tagged
// directly, and then the two tiers are the same row.
// Saying it twice would read as an error, so the
// objective is left out.
objective: (objective != id).then(|| course.text_for(objective)),
target: course.text_for(id),
}
})
.collect() .collect()
} else { } else {
// Only where it earns its space. Every question already carries its // Only where it earns its space. Every question already carries its
// objective ids, and the objectives table prints all of them. // target ids, and a correct answer needs no explaining.
Vec::new() Vec::new()
}, },
correct: row.correct, correct: row.correct,
@@ -802,7 +853,7 @@ fn lecture_focus(catalog: &Catalog, rows: &[&Response], opts: &Options) -> Vec<L
/// What has accumulated for one lecture so far. /// What has accumulated for one lecture so far.
#[derive(Default)] #[derive(Default)]
struct Tally { struct Tally {
objectives: BTreeSet<String>, targets: BTreeSet<String>,
questions: BTreeSet<u32>, questions: BTreeSet<u32>,
slides: BTreeSet<u32>, slides: BTreeSet<u32>,
} }
@@ -811,10 +862,9 @@ fn lecture_focus(catalog: &Catalog, rows: &[&Response], opts: &Options) -> Vec<L
let mut tallies: BTreeMap<String, Tally> = BTreeMap::new(); let mut tallies: BTreeMap<String, Tally> = BTreeMap::new();
for row in rows.iter().filter(|r| r.counts() && r.credit < 0.999) { 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 // Two routes to a lecture, and both are wanted. The registry knows
// knows which lectures develop an objective; the item knows which lecture // which lectures develop a target; the item knows which lecture it was
// it was written from, which is the finer answer when an objective spans // written from, which is the finer answer when a target spans several.
// several.
let mut lectures: BTreeSet<String> = BTreeSet::new(); let mut lectures: BTreeSet<String> = BTreeSet::new();
let mut slides: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new(); let mut slides: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
@@ -833,7 +883,7 @@ fn lecture_focus(catalog: &Catalog, rows: &[&Response], opts: &Options) -> Vec<L
for lecture in lectures { for lecture in lectures {
let tally = tallies.entry(lecture.clone()).or_default(); let tally = tallies.entry(lecture.clone()).or_default();
tally.objectives.extend(row.learning_targets.clone()); tally.targets.extend(row.learning_targets.clone());
tally.questions.insert(row.item_number); tally.questions.insert(row.item_number);
if let Some(numbers) = slides.get(&lecture) { if let Some(numbers) = slides.get(&lecture) {
tally.slides.extend(numbers.iter().copied()); tally.slides.extend(numbers.iter().copied());
@@ -850,7 +900,7 @@ fn lecture_focus(catalog: &Catalog, rows: &[&Response], opts: &Options) -> Vec<L
.map(|l| l.title.clone()) .map(|l| l.title.clone())
.unwrap_or_else(|| lecture.clone()), .unwrap_or_else(|| lecture.clone()),
url: record.and_then(|l| l.slides_url.clone()), url: record.and_then(|l| l.slides_url.clone()),
n_objectives: tally.objectives.len(), n_targets: tally.targets.len(),
n_questions: tally.questions.len(), n_questions: tally.questions.len(),
questions: if opts.questions { questions: if opts.questions {
tally.questions.iter().copied().collect() tally.questions.iter().copied().collect()
@@ -858,19 +908,15 @@ fn lecture_focus(catalog: &Catalog, rows: &[&Response], opts: &Options) -> Vec<L
Vec::new() Vec::new()
}, },
slides: tally.slides.iter().copied().collect(), slides: tally.slides.iter().copied().collect(),
objectives: tally targets: tally.targets.iter().map(|id| course.text_for(id)).collect(),
.objectives
.iter()
.map(|id| course.text_for(id))
.collect(),
lecture, lecture,
} }
}) })
.collect(); .collect();
out.sort_by(|a, b| { out.sort_by(|a, b| {
b.n_objectives b.n_targets
.cmp(&a.n_objectives) .cmp(&a.n_targets)
.then(b.n_questions.cmp(&a.n_questions)) .then(b.n_questions.cmp(&a.n_questions))
.then(a.lecture.cmp(&b.lecture)) .then(a.lecture.cmp(&b.lecture))
}); });
@@ -1087,11 +1133,11 @@ pub struct CohortQuestionRow {
/// The level code. /// The level code.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub level: Option<u8>, pub level: Option<u8>,
/// The objectives it measured. /// The learning targets it measured, by id.
pub objectives: Vec<String>, pub targets: Vec<String>,
/// What those objectives ask, in the course's own words. /// What those targets ask, in the course's own words.
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub objective_texts: Vec<String>, pub target_texts: Vec<String>,
/// Where the item was taught, as lecture titles and slide numbers. /// Where the item was taught, as lecture titles and slide numbers.
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub taught_in: Vec<String>, pub taught_in: Vec<String>,
@@ -1258,9 +1304,9 @@ pub struct TriageRow {
/// Upper minus lower group. /// Upper minus lower group.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub discrimination: Option<f64>, pub discrimination: Option<f64>,
/// What the question measured, in the course's words. /// What the question measured, in the course's words: its learning targets.
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub objectives: Vec<String>, pub targets: Vec<String>,
/// Where it was taught. /// Where it was taught.
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
pub taught_in: Vec<String>, pub taught_in: Vec<String>,
@@ -1465,8 +1511,8 @@ pub fn cohort(
number: item.number, number: item.number,
item: item.item_ref.clone(), item: item.item_ref.clone(),
level: meta.and_then(|m| m.0), level: meta.and_then(|m| m.0),
objectives: meta.map(|m| m.1.clone()).unwrap_or_default(), targets: meta.map(|m| m.1.clone()).unwrap_or_default(),
objective_texts: meta target_texts: meta
.map(|m| m.1.iter().map(|id| course.text_for(id)).collect()) .map(|m| m.1.iter().map(|id| course.text_for(id)).collect())
.unwrap_or_default(), .unwrap_or_default(),
taught_in: item taught_in: item
@@ -1727,27 +1773,32 @@ fn lecture_rows(
) -> Vec<CohortLectureRow> { ) -> Vec<CohortLectureRow> {
let course = &catalog.course; let course = &catalog.course;
let mut items: BTreeMap<String, Vec<&CohortQuestionRow>> = BTreeMap::new(); let mut items: BTreeMap<String, Vec<&CohortQuestionRow>> = BTreeMap::new();
// Keyed by objective, not by the target an item was tagged with: the rows
// this is matched against are objective rows, so collecting target ids here
// left every lookup empty and every count zero.
let mut lecture_objectives: BTreeMap<String, BTreeSet<String>> = BTreeMap::new(); let mut lecture_objectives: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for question in questions { for question in questions {
// The same two routes the student report uses: the item knows which // The same two routes the student report uses: the item knows which
// lecture it was written from, and the objective registry knows which // lecture it was written from, and the registry knows which lectures
// lectures develop it. // develop the target.
let mut lectures: BTreeSet<String> = BTreeSet::new(); let mut lectures: BTreeSet<String> = BTreeSet::new();
if let Some(entry) = question.item.as_deref().and_then(|uid| catalog.get(uid)) { if let Some(entry) = question.item.as_deref().and_then(|uid| catalog.get(uid)) {
for source in &entry.item.sources { for source in &entry.item.sources {
lectures.insert(source.lecture.clone()); lectures.insert(source.lecture.clone());
} }
} }
for target in &question.objectives { for target in &question.targets {
lectures.extend(course.lectures_for(target).iter().cloned()); lectures.extend(course.lectures_for(target).iter().cloned());
} }
for lecture in lectures { for lecture in lectures {
items.entry(lecture.clone()).or_default().push(question); items.entry(lecture.clone()).or_default().push(question);
lecture_objectives lecture_objectives.entry(lecture).or_default().extend(
.entry(lecture) question
.or_default() .targets
.extend(question.objectives.iter().cloned()); .iter()
.map(|t| course.objective_for(t).to_string()),
);
} }
} }
@@ -1861,7 +1912,7 @@ fn triage(questions: &[CohortQuestionRow], threshold: f64, default_options: usiz
p_value: question.p_value, p_value: question.p_value,
point_biserial: question.point_biserial, point_biserial: question.point_biserial,
discrimination: question.discrimination, discrimination: question.discrimination,
objectives: question.objective_texts.clone(), targets: question.target_texts.clone(),
taught_in: question.taught_in.clone(), taught_in: question.taught_in.clone(),
option: option.map(|o| o.letter.clone()), option: option.map(|o| o.letter.clone()),
option_share: option.map(|o| o.rate), option_share: option.map(|o| o.rate),
+30 -29
View File
@@ -145,14 +145,22 @@ pub fn student(
} }
// --- objectives // --- objectives
if opts.objectives && !summary.objectives.is_empty() { //
// Objectives only. A target row would rest on one question and could only
// ever read "not enough questions to say", so a mixed table buried the few
// real classifications among dozens of non-statements. The targets appear
// where they can be acted on instead: under each lecture in "what to
// revise", and beside each missed question.
if opts.objectives && summary.objectives.iter().any(|o| o.tier == Tier::Objective) {
out.push_str("## What this exam says about each learning objective\n\n"); out.push_str("## What this exam says about each learning objective\n\n");
out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n"); out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n");
for o in &summary.objectives { for o in summary
let you = format!("{:.0}%", o.rate * 100.0); .objectives
if o.tier == Tier::Objective { .iter()
// The classification, over every question that touched the .filter(|o| o.tier == Tier::Objective)
// objective. {
// How much of the objective this exam reached, which is the scope of
// the claim the row makes.
let scope = if o.targets_total > 0 { let scope = if o.targets_total > 0 {
format!( format!(
"{} ({} of {} targets tested)", "{} ({} of {} targets tested)",
@@ -164,37 +172,16 @@ pub fn student(
escape_pipes(&o.text) escape_pipes(&o.text)
}; };
out.push_str(&format!( out.push_str(&format!(
"| {} | **{}** | {} | {:.0}% | {} |\n", "| {} | {} | {:.0}% | {:.0}% | {} |\n",
o.status.symbol(), o.status.symbol(),
scope, scope,
you, o.rate * 100.0,
o.cohort_rate * 100.0, o.cohort_rate * 100.0,
o.n_items o.n_items
)); ));
} else {
// A target row is evidence for the objective above it, so it
// carries no symbol: one question does not classify anything,
// and printing "✗" against one question invites exactly that
// reading.
out.push_str(&format!(
"| | ⤷ {} | {} | {:.0}% | {} |\n",
escape_pipes(&o.text),
you,
o.cohort_rate * 100.0,
o.n_items
));
}
} }
out.push('\n'); out.push('\n');
out.push_str("✓ meeting · ~ developing · ✗ not yet · ? too few questions to tell\n\n"); out.push_str("✓ meeting · ~ developing · ✗ not yet · ? too few questions to tell\n\n");
if summary.objectives.iter().any(|o| o.tier == Tier::Target) {
out.push_str(
"Bold rows are the learning objectives. Indented rows are the learning targets \
inside each one, which is what individual questions were written against: \
they show where the marks went, and a single indented row is one question \
rather than a verdict.\n\n",
);
}
// The "too few questions" cases are an honest caveat about the exam, and // The "too few questions" cases are an honest caveat about the exam, and
// saying so protects the student from over-reading a single data point. // saying so protects the student from over-reading a single data point.
@@ -319,6 +306,20 @@ pub fn student(
String::new() String::new()
}; };
out.push_str(&format!("**Question {}**{partial}\n\n", m.number)); out.push_str(&format!("**Question {}**{partial}\n\n", m.number));
// Both tiers: the target says what this question asked, the
// objective says which row of the table above it counted toward.
for target in &m.learning_targets {
let objective = course.objective_for(target);
if objective == target.as_str() {
out.push_str(&format!("Asked you to: {}\n\n", course.text_for(target)));
} else {
out.push_str(&format!(
"Asked you to: {} \nCounts toward: {}\n\n",
course.text_for(target),
course.text_for(objective)
));
}
}
if let Some(text) = &m.feedback { if let Some(text) = &m.feedback {
out.push_str(&format!("{text}\n\n")); out.push_str(&format!("{text}\n\n"));
} else if let Some(misconception) = &m.misconception { } else if let Some(misconception) = &m.misconception {
+29 -14
View File
@@ -321,8 +321,8 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
value.insert_some("position", question.position.map(|p| Value::Int(p as i64))); value.insert_some("position", question.position.map(|p| Value::Int(p as i64)));
value.insert_some("level", question.level.map(|l| Value::Int(l as i64))); value.insert_some("level", question.level.map(|l| Value::Int(l as i64)));
value.insert( value.insert(
"objectives", "targets",
Value::Array(question.objectives.iter().map(Value::str).collect()), Value::Array(question.targets.iter().map(Value::str).collect()),
); );
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));
@@ -330,13 +330,28 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
value.insert("dropped", Value::Bool(question.dropped)); 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));
// Both tiers, as a list of pairs: the target says what this
// question asked, the objective says which row of the table
// above it counted toward. `objective` is absent when the
// tagged id is an objective with no targets, so the template
// does not print one sentence twice.
value.insert( value.insert(
"objective-texts", "measured",
Value::Array( Value::Array(
question question
.objective_texts .measured
.iter() .iter()
.map(|text| markup_value(text, content)) .map(|m| {
let mut pair = Value::dict();
pair.insert_some(
"objective",
m.objective
.as_ref()
.map(|text| markup_value(text, content)),
);
pair.insert("target", markup_value(&m.target, content));
pair
})
.collect(), .collect(),
), ),
); );
@@ -412,7 +427,7 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
value.insert("lecture", Value::str(&lecture.lecture)); value.insert("lecture", Value::str(&lecture.lecture));
value.insert("title", Value::str(&lecture.title)); value.insert("title", Value::str(&lecture.title));
value.insert_some("url", lecture.url.as_ref().map(Value::str)); value.insert_some("url", lecture.url.as_ref().map(Value::str));
value.insert("objectives-missed", Value::Int(lecture.n_objectives as i64)); value.insert("targets-missed", Value::Int(lecture.n_targets as i64));
value.insert("questions-missed", Value::Int(lecture.n_questions as i64)); value.insert("questions-missed", Value::Int(lecture.n_questions as i64));
value.insert( value.insert(
"questions", "questions",
@@ -435,10 +450,10 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
), ),
); );
value.insert( value.insert(
"objectives", "targets",
Value::Array( Value::Array(
lecture lecture
.objectives .targets
.iter() .iter()
.map(|text| markup_value(text, content)) .map(|text| markup_value(text, content))
.collect(), .collect(),
@@ -783,9 +798,9 @@ fn triage_value(row: &crate::diagnostic::TriageRow, content: bool) -> Value {
value.insert_some("point-biserial", row.point_biserial.map(Value::Float)); value.insert_some("point-biserial", row.point_biserial.map(Value::Float));
value.insert_some("discrimination", row.discrimination.map(Value::Float)); value.insert_some("discrimination", row.discrimination.map(Value::Float));
value.insert( value.insert(
"objectives", "targets",
Value::Array( Value::Array(
row.objectives row.targets
.iter() .iter()
.map(|text| markup_value(text, content)) .map(|text| markup_value(text, content))
.collect(), .collect(),
@@ -844,14 +859,14 @@ fn cohort_question_value(question: &CohortQuestionRow, content: bool) -> Value {
value.insert_some("item", question.item.as_ref().map(Value::str)); value.insert_some("item", question.item.as_ref().map(Value::str));
value.insert_some("level", question.level.map(|l| Value::Int(l as i64))); value.insert_some("level", question.level.map(|l| Value::Int(l as i64)));
value.insert( value.insert(
"objectives", "targets",
Value::Array(question.objectives.iter().map(Value::str).collect()), Value::Array(question.targets.iter().map(Value::str).collect()),
); );
value.insert( value.insert(
"objective-texts", "target-texts",
Value::Array( Value::Array(
question question
.objective_texts .target_texts
.iter() .iter()
.map(|text| markup_value(text, content)) .map(|text| markup_value(text, content))
.collect(), .collect(),
+9 -7
View File
@@ -110,8 +110,8 @@
number: 1, number: 1,
item: "bank::q-sample-001", item: "bank::q-sample-001",
level: 1, level: 1,
objectives: ("lo-sample-gap",), targets: ("t-sample-gap",),
objective-texts: ([A sample objective the class struggled with.],), target-texts: ([A sample learning target the class struggled with.],),
taught-in: ("Entropy (L1.2), slides 4, 5",), taught-in: ("Entropy (L1.2), slides 4, 5",),
lectures: ("L1.2",), lectures: ("L1.2",),
difficulty-band: "moderate", difficulty-band: "moderate",
@@ -349,12 +349,14 @@
// The objective and lecture context for one question. // The objective and lecture context for one question.
#let question-context(row) = { #let question-context(row) = {
let parts = () let parts = ()
// A question row carries ids in `objectives` and prose in `objective-texts`; a // A question row carries ids in `targets` and prose in `target-texts`; a
// triage row carries the prose under `objectives`. One lookup covers both. // triage row carries the prose under `targets`. One lookup covers both. This
let objectives = row.at("objective-texts", default: row.at("objectives", default: ())) // is the target tier on purpose: a row about one item should name the
if objectives.len() > 0 { // performance that item measured, not the broader claim it rolls up to.
let targets = row.at("target-texts", default: row.at("targets", default: ()))
if targets.len() > 0 {
parts.push(text(size: size-meta, fill: luma(105))[ parts.push(text(size: size-meta, fill: luma(105))[
#text(weight: "bold")[Measured:] #objectives.map(o => markup(o)).join([; ]) #text(weight: "bold")[Measured:] #targets.map(t => markup(t)).join([; ])
]) ])
} }
let taught = row.at("taught-in", default: ()) let taught = row.at("taught-in", default: ())
+53 -20
View File
@@ -117,8 +117,8 @@
( (
number: 1, number: 1,
level: 1, level: 1,
objectives: ("lo-sample-met",), targets: ("t-sample-met",),
objective-texts: (), measured: (),
correct: true, correct: true,
credit: 1.0, credit: 1.0,
bonus: false, bonus: false,
@@ -131,8 +131,11 @@
( (
number: 2, number: 2,
level: 3, level: 3,
objectives: ("lo-sample-gap",), targets: ("t-sample-gap",),
objective-texts: ([A sample objective to work on.],), measured: ((
objective: [A sample objective to work on.],
target: [A sample learning target under it.],
),),
correct: false, correct: false,
credit: 0.0, credit: 0.0,
bonus: false, bonus: false,
@@ -149,11 +152,11 @@
( (
lecture: "L1.1", lecture: "L1.1",
title: "Enthalpy", title: "Enthalpy",
objectives-missed: 2, targets-missed: 2,
questions-missed: 3, questions-missed: 3,
questions: (2, 14, 15), questions: (2, 14, 15),
slides: (12, 13), slides: (12, 13),
objectives: ([A sample objective to work on.], [A second one from the same lecture.]), targets: ([A sample learning target to work on.], [A second one from the same lecture.]),
), ),
), ),
study: ( study: (
@@ -537,9 +540,9 @@
= What the exam measured, objective by objective = What the exam measured, objective by objective
#explain[ #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. Each line is one learning objective, just as it appears in the syllabus. *Q* counts every question on this exam that measured any part of it, so a line usually rests on several questions rather than one. *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. The symbol in the first column is the summary. A #text(fill: thin-color, weight: "bold")[?] means this exam did not ask enough about that objective to say anything either way, which is a fact about the exam and not about you. Nothing on this page is broken down question by question; for that, see which lectures to go back to, and the notes on the ones you missed.
] ]
// The objective text is the only thing in this table that wants width, so it // The objective text is the only thing in this table that wants width, so it
@@ -658,10 +661,10 @@
// body is a stack, so the title, the provenance line, and the objectives are one // 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. // step apart and no paragraph contributes spacing of its own.
#let lecture-entry(index, lecture) = { #let lecture-entry(index, lecture) = {
let count = lecture.at("objectives-missed", default: 0) let count = lecture.at("targets-missed", default: 0)
let numbers = lecture.at("questions", default: ()) let numbers = lecture.at("questions", default: ())
let slides = lecture.at("slides", default: ()) let slides = lecture.at("slides", default: ())
let objectives = lecture.at("objectives", default: ()) let targets = lecture.at("targets", default: ())
let parts = () let parts = ()
@@ -690,10 +693,14 @@
parts.push(text(size: size-meta, fill: luma(115))[#trail.join(" · ")]) parts.push(text(size: size-meta, fill: luma(115))[#trail.join(" · ")])
} }
// The learning targets, not the objectives they belong to. This section
// answers "what do I go and restudy", and a target is the grain that can be
// acted on: it names one performance rather than a whole claim.
//
// A real list rather than a middot glued to the front of a paragraph, so the // 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 // second line of a long target indents under the first instead of running
// back to the margin. // back to the margin.
if objectives.len() > 0 { if targets.len() > 0 {
parts.push({ parts.push({
set text(size: size-small, fill: luma(80)) set text(size: size-small, fill: luma(80))
list( list(
@@ -701,7 +708,7 @@
body-indent: 0.45em, body-indent: 0.45em,
spacing: step * 0.7, spacing: step * 0.7,
marker: text(fill: luma(165))[·], marker: text(fill: luma(165))[·],
..objectives.map(objective => markup(objective)), ..targets.map(target => markup(target)),
) )
}) })
} }
@@ -714,7 +721,7 @@
rank(index + 1, if index == 0 { bad-color } else if count > 1 { mid-color } else { accent }), 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)], pad(right: prose-pad-inset)[#stack(dir: ttb, spacing: step, ..parts)],
text(size: size-micro, fill: luma(105))[ text(size: size-micro, fill: luma(105))[
#count #plural(count, "objective", "objectives") #count #plural(count, "target", "targets")
], ],
) )
] ]
@@ -727,7 +734,7 @@
= Which lectures to go back to = Which lectures to go back to
#explain[ #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. Each question connects to the lecture it came from. Sorting the lectures by how many separate things went wrong in each gives you an order to work through, hardest first. Under each lecture are the specific skills the questions were testing, so you can go to the part of it you need rather than rewatching the whole thing. Several from one lecture usually means an early idea did not land, and fixing that one is the cheapest repair.
] ]
#for (index, lecture) in review-lectures.enumerate() [ #for (index, lecture) in review-lectures.enumerate() [
@@ -856,11 +863,37 @@
#let note-entry(q) = { #let note-entry(q) = {
let parts = () let parts = ()
let texts = q.at("objective-texts", default: ()) // Both tiers. The target is what this question actually asked, which is the
if texts.len() > 0 { // thing to practise; the objective is the row it counted toward in the table
parts.push(text(fill: luma(21.57%))[ // above, which is how a student tells whether one slip cost them a claim. The
#text(weight: "bold")[Measuring:] #texts.map(t => markup(t)).join([; ]) // objective is absent when the two would be the same sentence.
]) let measured = q.at("measured", default: ())
if measured.len() > 0 {
parts.push({
set text(fill: luma(21.57%))
stack(
dir: ttb,
spacing: step * 0.45,
..measured.map(m => {
let objective = m.at("objective", default: none)
let target = [
#text(weight: "bold")[This question asked you to:] #markup(m.target)
]
if objective == none {
target
} else {
stack(
dir: ttb,
spacing: step * 0.3,
target,
text(size: size-small, fill: luma(110))[
Counts toward: #markup(objective)
],
)
}
}),
)
})
} }
// The diagnosis carries full body size. It is the sentence worth reading // The diagnosis carries full body size. It is the sentence worth reading