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::responses::{Response, ResponseSet};
use crate::students::{Cohort, Mastery, StudentSummary};
use crate::taxonomy::Level;
use crate::taxonomy::{Level, Tier};
/// What to assemble.
#[derive(Debug, Clone)]
@@ -164,6 +164,15 @@ pub struct StudentDiagnostic {
/// Per-level performance.
pub levels: Vec<LevelRow>,
/// 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>,
/// Objectives they are clearly meeting, worst first among the confident ones.
pub strengths: Vec<ObjectiveRef>,
@@ -299,6 +308,20 @@ pub struct ObjectiveRef {
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.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "kebab-case")]
@@ -311,15 +334,19 @@ pub struct QuestionRow {
/// The level code.
#[serde(skip_serializing_if = "Option::is_none")]
pub level: Option<u8>,
/// The objectives it measured.
pub objectives: Vec<String>,
/// What those objectives ask, in the words the course uses with students.
/// The learning targets it measured, by id.
pub targets: Vec<String>,
/// What this question measured, at both tiers.
///
/// 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.
/// Both, because each answers a different question a student has in front of
/// a missed item. The target says what this question actually asked of them,
/// 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")]
pub objective_texts: Vec<String>,
pub measured: Vec<Measured>,
/// Whether it was answered correctly.
#[serde(skip_serializing_if = "Option::is_none")]
pub correct: Option<bool>,
@@ -403,8 +430,14 @@ pub struct LectureFocus {
/// Where the slides live, when the course records that.
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// How many distinct objectives from this lecture were missed.
pub n_objectives: usize,
/// How many distinct learning targets from this lecture were missed. The
/// 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.
pub n_questions: usize,
/// 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.
#[serde(skip_serializing_if = "Vec::is_empty")]
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")]
pub objectives: Vec<String>,
pub targets: Vec<String>,
}
/// What to read about one objective.
@@ -508,6 +546,8 @@ pub fn student(
let objectives: Vec<ObjectiveRow> = summary
.objectives
.iter()
// Objectives only; see `StudentDiagnostic::objectives`.
.filter(|mastery| mastery.tier == Tier::Objective)
.map(|mastery| ObjectiveRow {
id: mastery.id.clone(),
text: mastery.text.clone(),
@@ -697,15 +737,26 @@ fn question_row(
number: row.item_number,
position: row.form_position.filter(|p| *p != row.item_number),
level: row.level.map(|l| l.code()),
objectives: row.learning_targets.clone(),
objective_texts: if missed {
targets: row.learning_targets.clone(),
measured: if missed {
let course = &catalog.course;
row.learning_targets
.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()
} else {
// 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()
},
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.
#[derive(Default)]
struct Tally {
objectives: BTreeSet<String>,
targets: BTreeSet<String>,
questions: 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();
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.
// Two routes to a lecture, and both are wanted. The registry knows
// which lectures develop a target; the item knows which lecture it was
// written from, which is the finer answer when a target spans several.
let mut lectures: BTreeSet<String> = BTreeSet::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 {
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);
if let Some(numbers) = slides.get(&lecture) {
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())
.unwrap_or_else(|| lecture.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(),
questions: if opts.questions {
tally.questions.iter().copied().collect()
@@ -858,19 +908,15 @@ fn lecture_focus(catalog: &Catalog, rows: &[&Response], opts: &Options) -> Vec<L
Vec::new()
},
slides: tally.slides.iter().copied().collect(),
objectives: tally
.objectives
.iter()
.map(|id| course.text_for(id))
.collect(),
targets: tally.targets.iter().map(|id| course.text_for(id)).collect(),
lecture,
}
})
.collect();
out.sort_by(|a, b| {
b.n_objectives
.cmp(&a.n_objectives)
b.n_targets
.cmp(&a.n_targets)
.then(b.n_questions.cmp(&a.n_questions))
.then(a.lecture.cmp(&b.lecture))
});
@@ -1087,11 +1133,11 @@ pub struct CohortQuestionRow {
/// The level code.
#[serde(skip_serializing_if = "Option::is_none")]
pub level: Option<u8>,
/// The objectives it measured.
pub objectives: Vec<String>,
/// What those objectives ask, in the course's own words.
/// The learning targets it measured, by id.
pub targets: Vec<String>,
/// What those targets ask, in the course's own words.
#[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.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub taught_in: Vec<String>,
@@ -1258,9 +1304,9 @@ pub struct TriageRow {
/// Upper minus lower group.
#[serde(skip_serializing_if = "Option::is_none")]
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")]
pub objectives: Vec<String>,
pub targets: Vec<String>,
/// Where it was taught.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub taught_in: Vec<String>,
@@ -1465,8 +1511,8 @@ pub fn cohort(
number: item.number,
item: item.item_ref.clone(),
level: meta.and_then(|m| m.0),
objectives: meta.map(|m| m.1.clone()).unwrap_or_default(),
objective_texts: meta
targets: meta.map(|m| m.1.clone()).unwrap_or_default(),
target_texts: meta
.map(|m| m.1.iter().map(|id| course.text_for(id)).collect())
.unwrap_or_default(),
taught_in: item
@@ -1727,27 +1773,32 @@ fn lecture_rows(
) -> Vec<CohortLectureRow> {
let course = &catalog.course;
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();
for question in questions {
// The same two routes the student report uses: the item knows which
// lecture it was written from, and the objective registry knows which
// lectures develop it.
// lecture it was written from, and the registry knows which lectures
// develop the target.
let mut lectures: BTreeSet<String> = BTreeSet::new();
if let Some(entry) = question.item.as_deref().and_then(|uid| catalog.get(uid)) {
for source in &entry.item.sources {
lectures.insert(source.lecture.clone());
}
}
for target in &question.objectives {
for target in &question.targets {
lectures.extend(course.lectures_for(target).iter().cloned());
}
for lecture in lectures {
items.entry(lecture.clone()).or_default().push(question);
lecture_objectives
.entry(lecture)
.or_default()
.extend(question.objectives.iter().cloned());
lecture_objectives.entry(lecture).or_default().extend(
question
.targets
.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,
point_biserial: question.point_biserial,
discrimination: question.discrimination,
objectives: question.objective_texts.clone(),
targets: question.target_texts.clone(),
taught_in: question.taught_in.clone(),
option: option.map(|o| o.letter.clone()),
option_share: option.map(|o| o.rate),