fix: exam report targets and objectives
This commit is contained in:
+97
-46
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user