feat: add learning objective targets
Pipeline / check (pull_request) Failing after 3m25s
Pipeline / docs (pull_request) Skipped
Pipeline / nightly (pull_request) Skipped
Pipeline / release (pull_request) Skipped

This commit is contained in:
2026-09-21 14:02:34 -04:00
parent 9755417899
commit 8ec48fb185
32 changed files with 2396 additions and 448 deletions
+1 -1
View File
@@ -1015,7 +1015,7 @@ mod tests {
score: credit,
response_time_seconds: None,
level: None,
learning_objectives: vec![],
learning_targets: vec![],
topics: vec![],
bonus: false,
dropped: false,
+24 -26
View File
@@ -509,12 +509,9 @@ pub fn student(
.objectives
.iter()
.map(|mastery| ObjectiveRow {
id: mastery.objective.clone(),
id: mastery.id.clone(),
text: mastery.text.clone(),
unit: course
.learning_objectives
.get(&mastery.objective)
.and_then(|o| o.unit.clone()),
unit: course.objective_unit(&mastery.id).map(str::to_string),
n_items: mastery.n_items,
credit: mastery.credit,
rate: mastery.rate,
@@ -700,11 +697,11 @@ 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_objectives.clone(),
objectives: row.learning_targets.clone(),
objective_texts: if missed {
row.learning_objectives
row.learning_targets
.iter()
.map(|id| catalog.course.objective_text(id))
.map(|id| catalog.course.text_for(id))
.collect()
} else {
// Only where it earns its space. Every question already carries its
@@ -830,15 +827,13 @@ fn lecture_focus(catalog: &Catalog, rows: &[&Response], opts: &Options) -> Vec<L
.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 target in &row.learning_targets {
lectures.extend(course.lectures_for(target).iter().cloned());
}
for lecture in lectures {
let tally = tallies.entry(lecture.clone()).or_default();
tally.objectives.extend(row.learning_objectives.clone());
tally.objectives.extend(row.learning_targets.clone());
tally.questions.insert(row.item_number);
if let Some(numbers) = slides.get(&lecture) {
tally.slides.extend(numbers.iter().copied());
@@ -866,7 +861,7 @@ fn lecture_focus(catalog: &Catalog, rows: &[&Response], opts: &Options) -> Vec<L
objectives: tally
.objectives
.iter()
.map(|id| course.objective_text(id))
.map(|id| course.text_for(id))
.collect(),
lecture,
}
@@ -1390,17 +1385,22 @@ pub fn cohort(
.collect();
// Objective counts come from the responses so that an objective assessed by
// two items is not reported as if it had one.
// two items is not reported as if it had one. Counted per objective, and by
// item number, so a question tagged with two of that objective's targets is
// one item here.
let mut objective_items: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();
for row in set.rows.iter().filter(|r| r.counts()) {
for objective in &row.learning_objectives {
for objective in &row.learning_targets {
objective_items
.entry(objective.clone())
.entry(course.objective_for(objective).to_string())
.or_default()
.insert(row.item_number);
}
}
// Objectives, not targets: this table is the reteaching queue, and it is
// only usable if it is short enough to read and each row rests on enough
// items to believe.
let mut objectives: Vec<CohortObjectiveRow> = cohort
.objective_rates
.iter()
@@ -1410,7 +1410,7 @@ pub fn cohort(
let mut not_yet = 0;
let mut thin = 0;
for student in &cohort.students {
if let Some(row) = student.objectives.iter().find(|o| &o.objective == id) {
if let Some(row) = student.objectives.iter().find(|o| &o.id == id) {
match row.status {
Mastery::Meeting => meeting += 1,
Mastery::Developing => developing += 1,
@@ -1421,7 +1421,7 @@ pub fn cohort(
}
CohortObjectiveRow {
id: id.clone(),
text: course.objective_text(id),
text: course.text_for(id),
n_items: objective_items.get(id).map(|s| s.len()).unwrap_or(0),
rate: *rate,
meeting,
@@ -1451,7 +1451,7 @@ pub fn cohort(
.map(|p| {
(
p.number,
(p.level.map(|l| l.code()), p.learning_objectives.clone()),
(p.level.map(|l| l.code()), p.learning_targets.clone()),
)
})
.collect();
@@ -1467,7 +1467,7 @@ pub fn cohort(
level: meta.and_then(|m| m.0),
objectives: meta.map(|m| m.1.clone()).unwrap_or_default(),
objective_texts: meta
.map(|m| m.1.iter().map(|id| course.objective_text(id)).collect())
.map(|m| m.1.iter().map(|id| course.text_for(id)).collect())
.unwrap_or_default(),
taught_in: item
.item_ref
@@ -1564,7 +1564,7 @@ pub fn cohort(
questions,
revise,
forms: form_rows(set, cohort),
blueprint: crate::select::check_blueprint(record),
blueprint: crate::select::check_blueprint(record, course),
patterns: cohort
.archetypes
.iter()
@@ -1739,10 +1739,8 @@ fn lecture_rows(
lectures.insert(source.lecture.clone());
}
}
for objective in &question.objectives {
if let Some(entry) = course.learning_objectives.get(objective) {
lectures.extend(entry.lectures.iter().cloned());
}
for target in &question.objectives {
lectures.extend(course.lectures_for(target).iter().cloned());
}
for lecture in lectures {
items.entry(lecture.clone()).or_default().push(question);
+325 -60
View File
@@ -24,6 +24,28 @@
//! uses the interval, so it is honest). A student can be "meeting" an objective
//! provisionally, and the report says so.
//!
//! # Which tier gets classified
//!
//! The registry has two tiers, objectives and their targets (see
//! [`crate::course::Objective`]), and they are reported differently because the
//! evidence behind them differs in kind. An **objective** is classified: its
//! denominator is every item tagged to any of its targets, which is how an exam
//! that spends twelve questions across a topic gets to make one statement with a
//! real denominator instead of twelve statements with none.
//!
//! A **target** is not classified. It usually carries one or two items, and
//! `min_items_for_mastery` would mark almost all of them "not enough evidence",
//! which would be true but useless. So target rows report the observed rate as
//! itemized evidence for the objective's classification, and a report should
//! present them that way: not "you have not mastered this" but "here is what you
//! missed inside the objective above".
//!
//! An item tagged with two targets of the same objective counts *once* toward
//! that objective. Double counting is right across unrelated objectives, where
//! the question "how is this student doing on kinetics" should use every item
//! that measured kinetics, but within one denominator it would inflate both the
//! count and the confidence.
//!
//! # Comparison to the cohort
//!
//! Per-level performance is reported against the class rather than in absolute
@@ -33,10 +55,10 @@
use std::collections::{BTreeMap, BTreeSet};
use crate::course::{CourseFile, Policy};
use crate::course::CourseFile;
use crate::responses::{Response, ResponseSet};
use crate::rng::Rng;
use crate::taxonomy::Level;
use crate::taxonomy::{Level, Tier};
/// How well a student has met one objective.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -74,14 +96,27 @@ impl Mastery {
}
}
/// One student's standing on one objective.
/// One student's standing on one registry entry, at either tier.
#[derive(Debug, Clone)]
pub struct ObjectiveMastery {
/// The objective id.
pub objective: String,
/// The registry id this row reports on, at either tier.
pub id: String,
/// The objective text, for reports.
pub text: String,
/// How many items on this objective the student saw.
/// Which tier this row is, since only one of them is a classification.
pub tier: Tier,
/// The objective this row sits under, for a target row.
pub objective: Option<String>,
/// For an objective row, how many of its targets the exam reached.
///
/// A student report can say "four of the nine things under this objective
/// were tested", which is the honest scope of the claim. Zero for a target
/// row and for an objective with no targets.
pub targets_seen: usize,
/// For an objective row, how many targets it has in the registry.
pub targets_total: usize,
/// How many items the student saw. For an objective row, items tagged to any
/// of its targets, counted once each.
pub n_items: usize,
/// How many they got right, counting partial credit.
pub credit: f64,
@@ -145,8 +180,8 @@ pub struct MissedItem {
pub credit: f64,
/// The level.
pub level: Option<Level>,
/// The objectives involved.
pub learning_objectives: Vec<String>,
/// The learning targets the question measured.
pub learning_targets: Vec<String>,
/// The misconception the chosen distractor was written to detect.
pub misconception: Option<String>,
/// Feedback written for a student who chose that option.
@@ -208,7 +243,13 @@ impl StudentSummary {
pub struct Cohort {
/// Per-student summaries, sorted by key.
pub students: Vec<StudentSummary>,
/// Class rate per objective.
/// Class rate per target, which is the tier items are tagged at. Use it to
/// drill into an objective the class missed.
pub target_rates: BTreeMap<String, f64>,
/// Class rate per objective, with each item counted once.
///
/// This is the class-level table worth acting on, and the one
/// [`Cohort::class_gaps`] is drawn from.
pub objective_rates: BTreeMap<String, f64>,
/// Class rate per level.
pub level_rates: BTreeMap<Level, f64>,
@@ -218,6 +259,11 @@ pub struct Cohort {
pub sd_percent: f64,
/// Objectives the class as a whole did not meet, worst first. This is the
/// list that should change what you reteach.
///
/// Objectives rather than targets, because a list of forty targets below
/// threshold is a list nobody reteaches from, and because a target that
/// carried one item on this exam does not support the claim that the class
/// missed it.
pub class_gaps: Vec<(String, f64)>,
/// Optional grouping of students by response profile.
pub archetypes: Vec<Archetype>,
@@ -289,7 +335,8 @@ pub fn summarize(
let students = set.students();
// Class rates first: every student's report is relative to these.
let objective_rates = rates_by_objective(&set.rows.iter().collect::<Vec<_>>());
let target_rates = rates_by_target(&set.rows.iter().collect::<Vec<_>>());
let objective_rates = rates_by_objective(&set.rows.iter().collect::<Vec<_>>(), course);
let level_rates = rates_by_level(&set.rows.iter().collect::<Vec<_>>());
// Per-level spread across students, for the z comparisons.
@@ -345,28 +392,62 @@ pub fn summarize(
.count();
let n_items = rows.iter().filter(|r| r.counts()).count();
// Objectives, in the course's declared order so reports read the way the
// course is taught rather than alphabetically.
let per_objective = rates_by_objective(&rows);
let counts = counts_by_objective(&rows);
// The registry in the course's declared order, so a report reads the way
// the course is taught rather than alphabetically. Each objective the
// exam reached is followed by the targets it reached, which is the order
// a report wants them in: the claim, then its evidence.
let target_counts = counts_by_target(&rows);
let objective_counts = counts_by_objective(&rows, course);
let mut objectives = Vec::new();
let mut seen: BTreeSet<&String> = BTreeSet::new();
for id in order.iter().chain(per_objective.keys()) {
if !seen.insert(id) {
let mut seen: BTreeSet<String> = BTreeSet::new();
// `order` puts each objective ahead of its own targets, so walking it
// produces the tiering. Anything the exam measured that the registry
// does not know about is appended afterwards rather than dropped.
let measured: Vec<String> = target_counts.keys().cloned().collect();
for id in order.iter().cloned().chain(measured) {
if !seen.insert(id.clone()) {
continue;
}
let Some((n, credit)) = counts.get(id).copied() else {
continue;
};
objectives.push(objective_mastery(
id,
course,
n,
credit,
objective_rates.get(id).copied().unwrap_or(0.0),
&rows,
policy,
));
if course.is_objective(&id) {
let Some((n, credit)) = objective_counts.get(&id).copied() else {
continue;
};
let targets = course.targets(&id);
let reached = targets
.iter()
.filter(|target| target_counts.contains_key(**target))
.count();
objectives.push(objective_mastery(
&id,
course,
n,
credit,
objective_rates.get(&id).copied().unwrap_or(0.0),
&rows,
policy.min_items_for_mastery.max(1),
reached,
targets.len(),
));
} else {
let Some((n, credit)) = target_counts.get(&id).copied() else {
continue;
};
// One item is the normal case for a target, so it is reported
// rather than withheld. The objective row above it carries the
// classification.
objectives.push(objective_mastery(
&id,
course,
n,
credit,
target_rates.get(&id).copied().unwrap_or(0.0),
&rows,
1,
0,
0,
));
}
}
// Levels.
@@ -400,25 +481,29 @@ pub fn summarize(
// not: telling a student to review something they may already know costs
// them an hour, while telling them they have mastered something they have
// not costs them the next exam.
//
// Both lists are drawn from objective rows only. A focus list built from
// targets is as long as the exam and tells a student to review forty
// things, which is the same as telling them nothing; the objective list
// is short enough to act on, and the target rows underneath it say what
// to look at within each one.
let strengths: Vec<String> = objectives
.iter()
.filter(|o| o.status == Mastery::Meeting && o.confident)
.map(|o| o.objective.clone())
.filter(|o| o.tier == Tier::Objective && o.status == Mastery::Meeting && o.confident)
.map(|o| o.id.clone())
.collect();
let mut focus_pairs: Vec<(&ObjectiveMastery, f64)> = objectives
.iter()
.filter(|o| o.tier == Tier::Objective)
.filter(|o| matches!(o.status, Mastery::NotYet | Mastery::Developing))
.map(|o| (o, o.rate))
.collect();
focus_pairs.sort_by(|a, b| {
a.1.partial_cmp(&b.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.objective.cmp(&b.0.objective))
.then_with(|| a.0.id.cmp(&b.0.id))
});
let focus: Vec<String> = focus_pairs
.iter()
.map(|(o, _)| o.objective.clone())
.collect();
let focus: Vec<String> = focus_pairs.iter().map(|(o, _)| o.id.clone()).collect();
let missed = missed_items(&rows, catalog, course);
@@ -460,6 +545,7 @@ pub fn summarize(
Cohort {
students: summaries,
target_rates,
objective_rates,
level_rates,
mean_percent,
@@ -469,21 +555,26 @@ pub fn summarize(
}
}
/// Builds one objective's mastery record.
/// Builds one objective's record, at either tier.
///
/// # Arguments
///
/// * `id` - the objective id.
/// * `course` - the course, for text and policy.
/// * `n` - items on this objective.
/// * `course` - the course, for text, tier, and policy.
/// * `n` - items counting toward this row.
/// * `credit` - total credit earned.
/// * `cohort_rate` - the class rate.
/// * `cohort_rate` - the class rate for the same row.
/// * `rows` - the student's responses, for the level list.
/// * `policy` - the course policy.
/// * `min_items` - items required before the row is classified. The policy's
/// `min_items_for_mastery` for an objective row, and 1 for a target row, which
/// is evidence rather than a classification.
/// * `targets_seen` - targets this exam reached, for an objective row.
/// * `targets_total` - targets in the registry, for an objective row.
///
/// # Returns
///
/// The record.
#[allow(clippy::too_many_arguments)]
fn objective_mastery(
id: &str,
course: &CourseFile,
@@ -491,12 +582,15 @@ fn objective_mastery(
credit: f64,
cohort_rate: f64,
rows: &[&Response],
policy: &Policy,
min_items: usize,
targets_seen: usize,
targets_total: usize,
) -> ObjectiveMastery {
let policy = &course.policy;
let rate = if n > 0 { credit / n as f64 } else { 0.0 };
let (lower, upper) = wilson(credit, n, 1.96);
let status = if n < policy.min_items_for_mastery.max(1) {
let status = if n < min_items.max(1) {
Mastery::NotEnoughEvidence
} else if rate >= policy.mastery_threshold {
Mastery::Meeting
@@ -506,17 +600,35 @@ fn objective_mastery(
Mastery::NotYet
};
// Levels the row was assessed at. For an objective row this is every level
// any of its targets was assessed at, which is what makes "met this
// objective" a checkable claim: meeting it on three Remember items is a
// different statement from meeting it on three Analyze items.
let levels: Vec<Level> = rows
.iter()
.filter(|r| r.learning_objectives.iter().any(|o| o == id))
.filter(|r| {
r.learning_targets
.iter()
.any(|t| t == id || course.objective_for(t) == id)
})
.filter_map(|r| r.level)
.collect::<BTreeSet<Level>>()
.into_iter()
.collect();
ObjectiveMastery {
objective: id.to_string(),
text: course.objective_text(id),
id: id.to_string(),
text: course.text_for(id),
tier: if course.is_objective(id) {
Tier::Objective
} else {
Tier::Target
},
objective: course
.is_target(id)
.then(|| course.objective_for(id).to_string()),
targets_seen,
targets_total,
n_items: n,
credit,
rate,
@@ -595,7 +707,7 @@ fn missed_items(
selected: r.chosen().to_vec(),
credit: r.credit,
level: r.level,
learning_objectives: r.learning_objectives.clone(),
learning_targets: r.learning_targets.clone(),
misconception,
feedback,
study,
@@ -612,9 +724,9 @@ fn missed_items(
///
/// # Returns
///
/// The rate for each objective mentioned.
pub fn rates_by_objective(rows: &[&Response]) -> BTreeMap<String, f64> {
counts_by_objective(rows)
/// The rate for each target mentioned.
pub fn rates_by_target(rows: &[&Response]) -> BTreeMap<String, f64> {
counts_by_target(rows)
.into_iter()
.map(|(id, (n, credit))| {
let rate = if n > 0 { credit / n as f64 } else { 0.0 };
@@ -623,11 +735,12 @@ pub fn rates_by_objective(rows: &[&Response]) -> BTreeMap<String, f64> {
.collect()
}
/// Item counts and credit per objective.
/// Item counts and credit per target, as tagged.
///
/// An item tagged with two objectives counts toward both. That double counting is
/// An item tagged with two targets counts toward both. That double counting is
/// intentional: the question "how is this student doing on kinetics" should use
/// every item that measured kinetics.
/// every item that measured kinetics. Roll-up to the objective, where the same
/// item must count once, is [`counts_by_objective`].
///
/// # Arguments
///
@@ -635,14 +748,14 @@ pub fn rates_by_objective(rows: &[&Response]) -> BTreeMap<String, f64> {
///
/// # Returns
///
/// `(item count, total credit)` per objective.
pub fn counts_by_objective(rows: &[&Response]) -> BTreeMap<String, (usize, f64)> {
/// `(item count, total credit)` per target.
pub fn counts_by_target(rows: &[&Response]) -> BTreeMap<String, (usize, f64)> {
let mut out: BTreeMap<String, (usize, f64)> = BTreeMap::new();
for r in rows {
if !r.counts() {
continue;
}
for objective in &r.learning_objectives {
for objective in &r.learning_targets {
let e = out.entry(objective.clone()).or_insert((0, 0.0));
e.0 += 1;
e.1 += r.credit.clamp(0.0, 1.0);
@@ -651,6 +764,66 @@ pub fn counts_by_objective(rows: &[&Response]) -> BTreeMap<String, (usize, f64)>
out
}
/// Item counts and credit per objective.
///
/// Each response contributes at most once to any one objective, even when it is
/// tagged with several of that objective's targets. Within a single denominator,
/// counting an item twice would inflate both the rate's weight and the
/// confidence interval's tightness, and the interval is the part of the report
/// that is supposed to stay honest. Across unrelated objectives an item still
/// counts toward each, as it does in [`counts_by_target`].
///
/// # Arguments
///
/// * `rows` - the responses.
/// * `course` - the course, for the objective each tagged target belongs to.
///
/// # Returns
///
/// `(item count, total credit)` per objective.
pub fn counts_by_objective(
rows: &[&Response],
course: &CourseFile,
) -> BTreeMap<String, (usize, f64)> {
let mut out: BTreeMap<String, (usize, f64)> = BTreeMap::new();
for r in rows {
if !r.counts() {
continue;
}
let objectives: BTreeSet<&str> = r
.learning_targets
.iter()
.map(|t| course.objective_for(t))
.collect();
for id in objectives {
let e = out.entry(id.to_string()).or_insert((0, 0.0));
e.0 += 1;
e.1 += r.credit.clamp(0.0, 1.0);
}
}
out
}
/// Credit rate per objective.
///
/// # Arguments
///
/// * `rows` - the responses.
/// * `course` - the course, for the objective each tagged target belongs to.
///
/// # Returns
///
/// The rate for each objective the responses reached.
pub fn rates_by_objective(rows: &[&Response], course: &CourseFile) -> BTreeMap<String, f64> {
counts_by_objective(rows, course)
.into_iter()
.map(|(id, (n, credit))| {
let rate = if n > 0 { credit / n as f64 } else { 0.0 };
(id, rate)
})
.collect()
}
/// Credit rate per level.
///
/// # Arguments
@@ -1007,21 +1180,113 @@ mod tests {
}
#[test]
fn objective_counts_credit_every_tagged_item() {
fn target_counts_credit_every_tagged_item() {
let rows = [
make("s1", 1, 1.0, &["lo-a", "lo-b"], Some(Level::Remember)),
make("s1", 2, 0.0, &["lo-a"], Some(Level::Apply)),
];
let refs: Vec<&Response> = rows.iter().collect();
let counts = counts_by_objective(&refs);
let counts = counts_by_target(&refs);
// lo-a saw both items; lo-b only the first.
assert_eq!(counts["lo-a"], (2, 1.0));
assert_eq!(counts["lo-b"], (1, 1.0));
let rates = rates_by_objective(&refs);
let rates = rates_by_target(&refs);
assert_eq!(rates["lo-a"], 0.5);
assert_eq!(rates["lo-b"], 1.0);
}
/// A course with one objective over three targets, plus an objective with
/// no targets of its own.
fn tiered_course() -> CourseFile {
serde_yaml_ng::from_str(
r#"
course: { code: X, title: Y, term: Z }
policy: { mastery_threshold: 0.75, min_items_for_mastery: 2 }
learning_objectives:
lo-binding: { text: Quantify binding., order: 1 }
lo-standalone: { text: Untiered objective., order: 2 }
learning_targets:
t-kd: { text: Write the expression., objective: lo-binding, order: 1 }
t-plot: { text: Read a plot., objective: lo-binding, order: 2 }
t-window: { text: State the switching window., objective: lo-binding, order: 3 }
"#,
)
.expect("course parses")
}
#[test]
fn items_on_targets_roll_up_to_their_objective() {
let course = tiered_course();
let rows = [
make("s1", 1, 1.0, &["t-kd"], Some(Level::Remember)),
make("s1", 2, 0.0, &["t-plot"], Some(Level::Apply)),
make("s1", 3, 1.0, &["t-window"], Some(Level::Understand)),
make("s1", 4, 1.0, &["lo-standalone"], Some(Level::Remember)),
];
let refs: Vec<&Response> = rows.iter().collect();
let objectives = counts_by_objective(&refs, &course);
// Three items, two credited, in one denominator.
assert_eq!(objectives["lo-binding"], (3, 2.0));
assert_eq!(objectives["lo-standalone"], (1, 1.0));
// The targets are not themselves objective rows.
assert!(!objectives.contains_key("t-kd"));
// As-tagged counts are still available for the drill-down.
let tagged = counts_by_target(&refs);
assert_eq!(tagged["t-kd"], (1, 1.0));
assert_eq!(tagged.len(), 4);
let rates = rates_by_objective(&refs, &course);
assert!((rates["lo-binding"] - 2.0 / 3.0).abs() < 1e-9);
}
#[test]
fn one_item_counts_once_toward_its_objective() {
let course = tiered_course();
// A single question tagged with two targets of the same objective.
let rows = [make(
"s1",
1,
0.0,
&["t-kd", "t-plot"],
Some(Level::Understand),
)];
let refs: Vec<&Response> = rows.iter().collect();
let objectives = counts_by_objective(&refs, &course);
assert_eq!(
objectives["lo-binding"],
(1, 0.0),
"one question is one item in the objective's denominator"
);
// Whereas as-tagged counting credits both targets, as it always has.
let tagged = counts_by_target(&refs);
assert_eq!(tagged["t-kd"], (1, 0.0));
assert_eq!(tagged["t-plot"], (1, 0.0));
}
#[test]
fn an_untiered_course_rolls_up_to_itself() {
// Adopting the second tier is optional: with no targets declared, every
// entry is an objective and the rolled-up counts equal the tagged ones.
let course: CourseFile = serde_yaml_ng::from_str(
r#"
course: { code: X, title: Y, term: Z }
learning_objectives:
lo-a: { text: A }
lo-b: { text: B }
"#,
)
.expect("course parses");
let rows = [
make("s1", 1, 1.0, &["lo-a", "lo-b"], Some(Level::Remember)),
make("s1", 2, 0.0, &["lo-a"], Some(Level::Apply)),
];
let refs: Vec<&Response> = rows.iter().collect();
assert_eq!(counts_by_objective(&refs, &course), counts_by_target(&refs));
}
#[test]
fn level_rates_ignore_untagged_items() {
let rows = [
@@ -1108,7 +1373,7 @@ mod tests {
score: credit,
response_time_seconds: None,
level,
learning_objectives: objectives.iter().map(|s| s.to_string()).collect(),
learning_targets: objectives.iter().map(|s| s.to_string()).collect(),
topics: vec![],
bonus: false,
dropped: false,
+68 -15
View File
@@ -338,6 +338,41 @@ fn lecture_schema() -> Value {
})
}
/// The schema for one learning target.
fn target_schema() -> Value {
json!({
"type": "object",
"required": ["text", "objective"],
"additionalProperties": false,
"properties": {
"text": text("The target as a student would read it. Start with a verb."),
"objective": {
"type": "string",
"description": "The objective this target belongs to. Required: a target with \
no objective would be measured and never reported."
},
"lectures": string_array("Lecture ids that cover this."),
"order": {
"type": "integer",
"minimum": 1,
"description": "Position among the other targets of the same objective, low \
first. Ordered within its objective rather than across the \
course, so inserting one renumbers nothing outside its group."
},
"level_ceiling": level(),
"prerequisites": string_array(
"Target or objective ids that must come first. Cycles are rejected."
),
"tags": string_array("Free-form tags."),
"assessed": {
"type": "boolean",
"description": "Set false for something you teach but do not test. An \
unassessed objective exempts its targets regardless."
}
}
})
}
/// The schema for one learning objective.
fn objective_schema() -> Value {
json!({
@@ -351,9 +386,8 @@ fn objective_schema() -> Value {
"order": {
"type": "integer",
"minimum": 1,
"description": "Position in teaching order, low first. A lecture page numbers \
objectives by this; without it they sort by id, which puts \
an objective before its own prerequisite."
"description": "Position in teaching order, low first. Without it objectives \
sort by id, which puts one before its own prerequisite."
},
"level_ceiling": level(),
"prerequisites": string_array(
@@ -363,7 +397,8 @@ fn objective_schema() -> Value {
"assessed": {
"type": "boolean",
"description": "Set false for an objective you teach but do not test; coverage \
reporting will stop flagging it as a gap."
reporting will stop flagging it as a gap. This exempts its \
targets too."
}
}
})
@@ -444,9 +479,12 @@ fn reading_mapping_schema() -> Value {
"enum": strings(&["assigned", "supplemental"]),
"description": "supplemental means offered but not separately assessed."
},
"objectives": string_array(
"Objective ids this reading serves. A student who misses one of these is \
pointed here, so the list is what makes study guidance specific."
"targets": string_array(
"Target ids this reading serves. A student who misses one of these is pointed \
here, so the list is what makes study guidance specific. Cite targets rather \
than objectives: a section of a book backs a specific performance, and a \
reading list resolved from an objective would send a student the same six \
sections whichever part of it they missed."
),
"summary": text("What the section contains."),
"focus": text("What to take from it. This is the sentence a student report quotes."),
@@ -501,10 +539,20 @@ fn course_schema() -> Value {
},
"learning_objectives": {
"type": "object",
"description": "Objectives by id. Everything downstream — coverage, mastery, \
student reports — keys off these.",
"description": "Learning objectives by id, conventionally `lo-...`. The tier a \
syllabus lists and a report classifies as met. Objectives only: \
the performances they are met by go in `learning_targets`.",
"additionalProperties": objective_schema()
},
"learning_targets": {
"type": "object",
"description": "Learning targets by id. Each names the objective it belongs to. \
This is the tier items are tagged to and readings are cited \
against; results roll up to the objective. Ids must not start \
with `lo`, so that an id in an item or a report says which tier \
it belongs to without a lookup — `t-...` is the convention.",
"additionalProperties": target_schema()
},
"stimuli": {
"type": "object",
"description": "Shared passages, figures, or data that several items refer to.",
@@ -880,9 +928,11 @@ fn item_content_properties() -> Value {
"items": option_schema()
},
"solution": solution_schema(),
"learning_objectives": string_array(
"Objective ids this item measures. Reports aggregate on these, so an item with none \
contributes to nothing."
"learning_targets": string_array(
"Target ids this item measures. Reports aggregate these onto the target's objective, \
so an item with none contributes to nothing. Name the target rather than the \
objective: the objective follows from it, and recording which target was asked \
about is what lets a report explain a result instead of only stating it."
),
"sources": {
"type": "array",
@@ -948,7 +998,7 @@ fn bank_scope_schema() -> Value {
outside it.",
"properties": {
"lectures": string_array("Lecture ids."),
"learning_objectives": string_array("Objective ids."),
"learning_targets": string_array("Target ids."),
"units": string_array("Unit ids."),
"topics": string_array("Topics.")
}
@@ -1044,7 +1094,10 @@ fn blueprint_schema() -> Value {
"type": "object",
"description": "Minimum items per objective. Placed before level quotas, because \
a coverage requirement is the constraint most likely to become \
unsatisfiable.",
unsatisfiable. A requirement is satisfied by items on any of \
that objective's targets, so this stays short: name the dozen \
objectives the exam is meant to cover, not the hundred targets \
it is built from.",
"additionalProperties": { "type": "integer", "minimum": 0 }
},
"lectures": string_array("Restrict the draw to these lectures."),
@@ -1106,7 +1159,7 @@ fn placement_schema() -> Value {
"bonus": { "type": "boolean" },
"key": string_array("Keyed option letters as administered."),
"level": level(),
"learning_objectives": string_array("Objectives as administered."),
"learning_targets": string_array("Targets as administered."),
"credit_overrides": {
"type": "object",
"description": "Partial credit decided after the fact, by option letter. Recording \
+98 -15
View File
@@ -29,7 +29,7 @@ use std::collections::{BTreeMap, BTreeSet};
use crate::assessment::{Assessment, AssessmentFile, Blueprint, Form, Kind, Placement, Platform};
use crate::catalog::Catalog;
use crate::course::SCHEMA_VERSION;
use crate::course::{CourseFile, SCHEMA_VERSION};
use crate::date::Date;
use crate::error::{Error, Result};
use crate::history::History;
@@ -106,10 +106,20 @@ pub fn select(
// to become unsatisfiable once the level quotas are full.
for (objective, needed) in &blueprint.objective_minimums {
let mut have = 0;
// A requirement written against an objective is satisfied by items on
// any of its targets, which is the only way a coverage requirement stays
// writable: a blueprint that had to name each target separately would be
// as long as the registry, and would need editing every time an
// objective gained one.
let mut candidates: Vec<&crate::catalog::Entry> = eligible
.iter()
.copied()
.filter(|e| e.item.learning_objectives.iter().any(|o| o == objective))
.filter(|e| {
e.item
.learning_targets
.iter()
.any(|t| t == objective || catalog.course.objective_for(t) == objective)
})
.filter(|e| !e.item.bonus)
.collect();
rank(&mut candidates, history, seed, "objective");
@@ -465,7 +475,7 @@ pub fn to_record(
bonus: is_bonus || e.item.bonus,
key: e.item.key_letters(),
level: Some(e.item.level),
learning_objectives: e.item.learning_objectives.clone(),
learning_targets: e.item.learning_targets.clone(),
credit_overrides: BTreeMap::new(),
dropped: false,
dropped_as: None,
@@ -585,11 +595,14 @@ pub fn option_order(form: &Form, uid: &str, n: usize) -> Vec<usize> {
/// # Arguments
///
/// * `record` - the assessment record.
/// * `course` - the course, for the objective each tagged target belongs to. An
/// objective minimum is satisfied by items on any of that objective's
/// targets, the same way [`select`] fills it.
///
/// # Returns
///
/// One message per discrepancy, empty when the form matches the design.
pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
pub fn check_blueprint(record: &AssessmentFile, course: &CourseFile) -> Vec<String> {
let Some(bp) = &record.blueprint else {
return vec!["the record carries no blueprint to check against".into()];
};
@@ -609,7 +622,11 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
let got = record
.items
.iter()
.filter(|p| p.learning_objectives.iter().any(|o| o == objective))
.filter(|p| {
p.learning_targets
.iter()
.any(|t| t == objective || course.objective_for(t) == objective)
})
.count();
if got < *needed {
out.push(format!(
@@ -620,7 +637,7 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
out
}
/// The set of objectives an assessment covers.
/// The set of learning targets an assessment covers, as tagged.
///
/// # Arguments
///
@@ -628,12 +645,36 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
///
/// # Returns
///
/// The objective ids, deduplicated.
pub fn covered_objectives(record: &AssessmentFile) -> BTreeSet<String> {
/// The target ids, deduplicated.
pub fn covered_targets(record: &AssessmentFile) -> BTreeSet<String> {
record
.items
.iter()
.flat_map(|p| p.learning_objectives.iter().cloned())
.flat_map(|p| p.learning_targets.iter().cloned())
.collect()
}
/// The objectives an assessment covers, through the targets it measured.
///
/// The list a coverage claim should be made from: "this exam covered eleven of
/// the course's thirty-two objectives" is a sentence about the blueprint, while
/// the same count over targets is a sentence about how finely the course happens
/// to be subdivided.
///
/// # Arguments
///
/// * `record` - the assessment record.
/// * `course` - the course, for the objective each tagged target belongs to.
///
/// # Returns
///
/// The objective ids, deduplicated.
pub fn covered_objectives(record: &AssessmentFile, course: &CourseFile) -> BTreeSet<String> {
record
.items
.iter()
.flat_map(|p| p.learning_targets.iter())
.map(|t| course.objective_for(t).to_string())
.collect()
}
@@ -690,12 +731,14 @@ blueprint:
level_counts: { 1: 2, 3: 1 }
objective_minimums: { lo-key: 2 }
items:
- { number: 1, item: "b::q-1", level: 1, learning_objectives: [lo-key] }
- { number: 1, item: "b::q-1", level: 1, learning_targets: [lo-key] }
- { number: 2, item: "b::q-2", level: 1 }
"#,
)
.unwrap();
let issues = check_blueprint(&record);
let course: CourseFile =
serde_yaml_ng::from_str("course: { code: C, title: T, term: M }").unwrap();
let issues = check_blueprint(&record, &course);
assert!(issues.iter().any(|i| i.contains("level 3")), "{issues:?}");
assert!(issues.iter().any(|i| i.contains("lo-key")), "{issues:?}");
// Level 1 matches, so it must not be reported.
@@ -703,17 +746,57 @@ items:
}
#[test]
fn covered_objectives_deduplicates() {
fn an_objective_minimum_is_met_by_items_on_its_targets() {
// The blueprint names the objective a report will classify; the items
// are tagged with the specific performances they measure.
let record: AssessmentFile = serde_yaml_ng::from_str(
r#"
assessment: { id: x, title: X }
blueprint:
objective_minimums: { lo-binding: 2 }
items:
- { number: 1, item: "b::q-1", level: 1, learning_targets: [t-kd] }
- { number: 2, item: "b::q-2", level: 3, learning_targets: [t-plot] }
"#,
)
.unwrap();
let course: CourseFile = serde_yaml_ng::from_str(
r#"
course: { code: C, title: T, term: M }
learning_objectives:
lo-binding: { text: Quantify binding. }
learning_targets:
t-kd: { text: Write the expression., objective: lo-binding }
t-plot: { text: Read a plot., objective: lo-binding }
"#,
)
.unwrap();
let issues = check_blueprint(&record, &course);
assert!(
!issues.iter().any(|i| i.contains("lo-binding")),
"two items on its targets satisfy it: {issues:?}"
);
assert_eq!(
covered_objectives(&record, &course)
.into_iter()
.collect::<Vec<_>>(),
vec!["lo-binding"],
"coverage is claimed at the tier the blueprint is written in"
);
}
#[test]
fn covered_targets_deduplicates() {
let record: AssessmentFile = serde_yaml_ng::from_str(
r#"
assessment: { id: x, title: X }
items:
- { number: 1, item: "b::q-1", learning_objectives: [lo-a, lo-b] }
- { number: 2, item: "b::q-2", learning_objectives: [lo-a] }
- { number: 1, item: "b::q-1", learning_targets: [lo-a, lo-b] }
- { number: 2, item: "b::q-2", learning_targets: [lo-a] }
"#,
)
.unwrap();
let set = covered_objectives(&record);
let set = covered_targets(&record);
assert_eq!(set.len(), 2);
assert!(set.contains("lo-a"));
}
+21 -7
View File
@@ -134,7 +134,7 @@ pub(crate) enum LectureCommand {
/// Write the readings block for one lecture.
///
/// The course file is the source of truth for what a lecture assigns and why,
/// so the list on the website is generated from it. Objective numbers are
/// so the list on the website is generated from it. Target numbers are
/// positional and are resolved here rather than authored.
Readings {
/// Lecture id, e.g. L1.1.
@@ -146,11 +146,10 @@ pub(crate) enum LectureCommand {
#[arg(long)]
out: Option<PathBuf>,
},
/// Write the objectives block for one lecture, grouped by level.
/// Write the learning objectives block for one lecture, grouped by level.
///
/// The numbering comes from the same place as the `_(LO 4, 7)_` lists in
/// `readings`, so generating one and hand-writing the other is what this
/// exists to prevent.
/// The short page: the handful of claims the lecture is accountable for.
/// The performances behind them are `lecture targets`.
Objectives {
/// Lecture id, e.g. L1.1.
id: String,
@@ -161,9 +160,24 @@ pub(crate) enum LectureCommand {
#[arg(long)]
out: Option<PathBuf>,
},
/// Show the readings behind each objective, and which objectives have none.
/// Write the learning targets block for one lecture, grouped by objective.
///
/// The numbering comes from the same place as the `_(T 4, 7)_` lists in
/// `readings`, so generating one and hand-writing the other is what this
/// exists to prevent.
Targets {
/// Lecture id, e.g. L1.1.
id: String,
/// Which flavour of Markdown to write.
#[arg(long, value_enum, default_value = "quarto")]
style: StyleArg,
/// Output path; prints to stdout when omitted.
#[arg(long)]
out: Option<PathBuf>,
},
/// Show the readings behind each target, and which targets have none.
Coverage {
/// Only this lecture's objectives.
/// Only this lecture's targets.
#[arg(long)]
lecture: Option<String>,
},
+1 -1
View File
@@ -426,7 +426,7 @@ pub(crate) fn analyze(cli: &Cli, sub: &AnalyzeCommand) -> Result<Outcome> {
println!(
" {:>4.0}% {}",
rate * 100.0,
catalog.course.objective_text(objective)
catalog.course.text_for(objective)
);
}
}
+1 -1
View File
@@ -184,7 +184,7 @@ pub(crate) fn assemble(cli: &Cli, args: &AssembleArgs) -> Result<Outcome> {
print_record(&catalog, &record);
let drift = select::check_blueprint(&record);
let drift = select::check_blueprint(&record, &catalog.course);
if !drift.is_empty() {
println!("\nBlueprint not fully satisfied:");
for d in &drift {
+18 -12
View File
@@ -2,7 +2,7 @@
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Rendering lecture pages, and checking what backs each objective.
//! Rendering lecture pages, and checking what backs each learning target.
//!
//! Both handlers here read the course file and nothing else, so neither needs a
//! bank or a single response. That is deliberate: a reading list is useful in week
@@ -10,7 +10,7 @@
use coursebank::course::CourseFile;
use coursebank::error::Result;
use coursebank::lecture::{objectives_markdown, readings_markdown};
use coursebank::lecture::{objectives_markdown, readings_markdown, targets_markdown};
use coursebank::yaml;
use crate::cli::{Cli, LectureCommand};
@@ -29,6 +29,10 @@ pub(crate) fn lecture(cli: &Cli, sub: &LectureCommand) -> Result<Outcome> {
objectives_markdown(&course, id, style.as_style())?,
out.as_deref(),
),
LectureCommand::Targets { id, style, out } => emit(
targets_markdown(&course, id, style.as_style())?,
out.as_deref(),
),
LectureCommand::Coverage { lecture: only } => coverage(&course, only.as_deref(), cli.quiet),
}
}
@@ -45,19 +49,21 @@ fn emit(markdown: String, out: Option<&std::path::Path>) -> Result<Outcome> {
Ok(Outcome::Ok)
}
/// Prints the readings behind each objective.
/// Prints the readings behind each learning target.
///
/// Returns [`Outcome::Findings`] when an assessed objective has no reading, since
/// that is the case where a student report can name what was missed but not where
/// to go and read about it.
/// Targets rather than objectives, because that is the tier a reading is cited
/// against: a section of a book backs a specific performance. Returns
/// [`Outcome::Findings`] when an assessed target has no reading, since that is
/// the case where a student report can name what was missed but not where to go
/// and read about it.
fn coverage(course: &CourseFile, lecture: Option<&str>, quiet: bool) -> Result<Outcome> {
let ids: Vec<String> = match lecture {
Some(l) => course
.lecture_objectives(l)
.lecture_targets(l)
.into_iter()
.map(str::to_string)
.collect(),
None => course.objectives_in_order(),
None => course.targets_in_order(),
};
for id in &ids {
@@ -83,21 +89,21 @@ fn coverage(course: &CourseFile, lecture: Option<&str>, quiet: bool) -> Result<O
if let Some(focus) = &reading.focus {
println!(
" {}",
course.expand_objective_refs(focus, |o| { course.objective_text(o) })
course.expand_objective_refs(focus, |id| { course.text_for(id) })
);
}
}
}
let gaps = course.objectives_without_readings();
let gaps = course.targets_without_readings();
if gaps.is_empty() {
if !quiet {
println!("\nevery assessed objective has a reading behind it");
println!("\nevery assessed target has a reading behind it");
}
return Ok(Outcome::Ok);
}
println!(
"\n{} assessed objective(s) with no reading, so a student report cannot say \
"\n{} assessed target(s) with no reading, so a student report cannot say \
where to go back to:",
gaps.len()
);
+20 -6
View File
@@ -20,7 +20,7 @@ use coursebank::error::{Error, Result};
use coursebank::jsonschema;
use coursebank::layout::Layout;
use coursebank::lint::{self, Rule};
use coursebank::taxonomy::Level;
use coursebank::taxonomy::{Level, Tier};
use coursebank::yaml;
use crate::cli::{CatalogArgs, Cli, InitArgs, LintArgs};
@@ -77,7 +77,7 @@ pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result<Outcome> {
write_gitignore(&cli.course.join(".gitignore"))?;
println!(
"\nNext: edit {} to add your learning objectives and lectures, then\n \
"\nNext: edit {} to add your learning objectives, their targets, and your\n lectures, then\n \
coursebank bank new unit-1 --title \"Unit 1\"\n coursebank validate",
COURSE_FILE
);
@@ -387,15 +387,29 @@ pub(crate) fn catalog(cli: &Cli, args: &CatalogArgs) -> Result<Outcome> {
let coverage = catalog.coverage();
println!("\nObjective coverage:");
println!(
" {:<40} {:>6} {:>6} MAX LEVEL",
"OBJECTIVE", "ITEMS", "READY"
" {:<44} {:>6} {:>6} {:>7} MAX LEVEL",
"OBJECTIVE / TARGET", "ITEMS", "READY", "TARGETS"
);
for row in &coverage.rows {
// Objectives carry the totals and are the tier a blueprint is
// written at, so they are the rows to scan; the indented target rows
// say where inside each one the items sit.
let label = if row.tier == Tier::Objective {
truncate(&row.id, 44)
} else {
truncate(&format!(" - {}", row.id), 44)
};
let targets = if row.targets > 0 {
format!("{}/{}", row.targets_covered, row.targets)
} else {
"-".to_string()
};
println!(
" {:<40} {:>6} {:>6} {}",
truncate(&row.objective, 40),
" {:<44} {:>6} {:>6} {:>7} {}",
label,
row.total,
row.assemblable,
targets,
row.max_level
.map(|l| l.code().to_string())
.unwrap_or_else(|| "-".into())
+1 -1
View File
@@ -347,7 +347,7 @@ pub fn ingest(
score,
response_time_seconds: None,
level: None,
learning_objectives: Vec::new(),
learning_targets: Vec::new(),
topics: Vec::new(),
bonus: false,
dropped: false,
+1 -1
View File
@@ -705,7 +705,7 @@ mod tests {
score: 0.0,
response_time_seconds: None,
level: None,
learning_objectives: Vec::new(),
learning_targets: Vec::new(),
topics: Vec::new(),
bonus: false,
dropped: false,
+1 -1
View File
@@ -655,7 +655,7 @@ pub fn to_responses(questions: &[Question], ctx: &Context) -> Import {
score: row.score,
response_time_seconds: None,
level: None,
learning_objectives: Vec::new(),
learning_targets: Vec::new(),
topics: Vec::new(),
bonus,
dropped: false,
+15 -10
View File
@@ -101,7 +101,7 @@ pub struct Response {
/// The item's level, denormalized so analysis need not carry the catalog.
pub level: Option<Level>,
/// The item's learning objectives, denormalized for per-objective mastery.
pub learning_objectives: Vec<String>,
pub learning_targets: Vec<String>,
/// The item's topics, denormalized.
pub topics: Vec<String>,
/// Whether the item was bonus, and so excluded from the scored total.
@@ -435,10 +435,10 @@ impl ResponseSet {
if let Some(cat) = catalog {
if let Some(entry) = cat.get(&p.item) {
r.level = Some(entry.item.level);
r.learning_objectives = if p.learning_objectives.is_empty() {
entry.item.learning_objectives.clone()
r.learning_targets = if p.learning_targets.is_empty() {
entry.item.learning_targets.clone()
} else {
p.learning_objectives.clone()
p.learning_targets.clone()
};
r.topics = entry.item.topics.clone();
if r.points_possible == 0.0 && !p.bonus {
@@ -447,7 +447,7 @@ impl ResponseSet {
}
} else {
r.level = p.level;
r.learning_objectives = p.learning_objectives.clone();
r.learning_targets = p.learning_targets.clone();
}
// Apply the record's credit overrides, which is how a decision to
@@ -652,8 +652,13 @@ pub struct FlatResponse {
pub response_time_seconds: String,
/// The level code 1..5, 0 when unknown.
pub level: u8,
/// Comma-joined objective ids.
pub learning_objectives: String,
/// Comma-joined target ids.
///
/// The column keeps its original name. Renaming a column in a store that
/// already holds collected administrations would make last term's responses
/// unreadable, and no vocabulary improvement is worth that.
#[serde(rename = "learning_objectives")]
pub learning_targets: String,
/// Comma-joined topics.
pub topics: String,
/// Whether the item was bonus.
@@ -722,7 +727,7 @@ impl FlatResponse {
.map(|s| format!("{s:.1}"))
.unwrap_or_default(),
level: r.level.map(|l| l.code()).unwrap_or(0),
learning_objectives: r.learning_objectives.join(","),
learning_targets: r.learning_targets.join(","),
topics: r.topics.join(","),
bonus: r.bonus,
dropped: r.dropped,
@@ -782,7 +787,7 @@ impl FlatResponse {
score: self.score,
response_time_seconds: self.response_time_seconds.parse().ok(),
level: Level::from_code(self.level),
learning_objectives: split(&self.learning_objectives),
learning_targets: split(&self.learning_targets),
topics: split(&self.topics),
bonus: self.bonus,
dropped: self.dropped,
@@ -830,7 +835,7 @@ mod tests {
score: credit * 2.0,
response_time_seconds: None,
level: None,
learning_objectives: vec![],
learning_targets: vec![],
topics: vec![],
bonus: false,
dropped: false,
+2 -2
View File
@@ -560,7 +560,7 @@ mod tests {
score: 1.5,
response_time_seconds: None,
level: None,
learning_objectives: vec!["lo-a".into()],
learning_targets: vec!["lo-a".into()],
topics: vec![],
bonus: false,
dropped: false,
@@ -593,7 +593,7 @@ mod tests {
assert_eq!(back.rows.len(), 2);
assert_eq!(back.rows[0].item_ref.as_deref(), Some("bank::q-x-001"));
assert_eq!(back.rows[0].selected, vec!["C".to_string()]);
assert_eq!(back.rows[0].learning_objectives, vec!["lo-a".to_string()]);
assert_eq!(back.rows[0].learning_targets, vec!["lo-a".to_string()]);
let all = store.read_all().unwrap();
assert_eq!(all.rows.len(), 2);
+7 -5
View File
@@ -57,6 +57,8 @@ pub fn schema() -> Schema {
Field::new("score", DataType::Float64, false),
Field::new("response_time_seconds", DataType::Utf8, false),
Field::new("level", DataType::UInt32, false),
// The stored column name predates the objective/target rename and is left
// alone: an existing parquet file has to keep loading.
Field::new("learning_objectives", DataType::Utf8, false),
Field::new("topics", DataType::Utf8, false),
Field::new("bonus", DataType::Boolean, false),
@@ -120,7 +122,7 @@ fn to_batch(rows: &[FlatResponse]) -> Result<RecordBatch> {
f64c(|r| r.score),
s(|r| &r.response_time_seconds),
u32c(|r| r.level as u32),
s(|r| &r.learning_objectives),
s(|r| &r.learning_targets),
s(|r| &r.topics),
boolc(|r| r.bonus),
boolc(|r| r.dropped),
@@ -285,7 +287,7 @@ fn from_batch(batch: &RecordBatch, path: &Path) -> Result<Vec<FlatResponse>> {
let score = floats("score")?;
let response_time_seconds = strings("response_time_seconds")?;
let level = uints("level")?;
let learning_objectives = strings("learning_objectives")?;
let learning_targets = strings("learning_objectives")?;
let topics = strings("topics")?;
let bonus = bools("bonus")?;
let dropped = bools("dropped")?;
@@ -326,7 +328,7 @@ fn from_batch(batch: &RecordBatch, path: &Path) -> Result<Vec<FlatResponse>> {
score: score.value(i),
response_time_seconds: response_time_seconds.value(i).to_string(),
level: level.value(i) as u8,
learning_objectives: learning_objectives.value(i).to_string(),
learning_targets: learning_targets.value(i).to_string(),
topics: topics.value(i).to_string(),
bonus: bonus.value(i),
dropped: dropped.value(i),
@@ -372,7 +374,7 @@ mod tests {
score: 1.5,
response_time_seconds: "42.0".into(),
level: 3,
learning_objectives: "lo-a,lo-b".into(),
learning_targets: "lo-a,lo-b".into(),
topics: "kinetics".into(),
bonus: false,
dropped: false,
@@ -404,7 +406,7 @@ mod tests {
assert_eq!(back.len(), 2);
assert_eq!(back[0].student_key, "s1");
assert_eq!(back[1].item_number, 2);
assert_eq!(back[0].learning_objectives, "lo-a,lo-b");
assert_eq!(back[0].learning_targets, "lo-a,lo-b");
assert_eq!(back[0].credit, 1.0);
assert_eq!(back[0].level, 3);
assert!(!back[0].bonus);
+324 -49
View File
@@ -9,10 +9,13 @@
//! hand. Two copies of the same prose drift within a term; one copy and a build
//! step do not.
//!
//! Three pages come out of here: the lecture's learning objectives, its learning
//! targets grouped under those objectives, and its readings.
//!
//! [`Style::Quarto`] reproduces the definition-list shape a Quarto page wants,
//! with `_(LO 4, 7)_` numbering resolved from [`CourseFile::lecture_objectives`].
//! Those numbers are positional and so cannot be authored: inserting an objective
//! renumbers everything after it. They are computed here and never stored.
//! with `_(T 4, 7)_` numbering resolved from [`numbered_targets`]. Those numbers
//! are positional and so cannot be authored: inserting a target renumbers
//! everything after it. They are computed here and never stored.
//!
//! What this module does not do is invent prose. Everything printed comes from
//! `summary`, `focus`, and `skip` on the reading, in that order, and a reading with
@@ -33,12 +36,131 @@ pub enum Style {
Plain,
}
/// Renders the objectives for one lecture, grouped by level.
/// How a lecture's targets group under its objectives.
///
/// The numbering here and the `_(LO 4, 7)_` lists in [`readings_markdown`] come
/// from the same call to [`CourseFile::lecture_objectives`], so they cannot
/// disagree. Generating one half of the page and hand-writing the other is how you
/// get a note pointing at LO 8 when LO 8 has become LO 9.
/// One function feeds every page, so the printed order and the numbering cannot
/// come apart.
///
/// # Arguments
///
/// * `course` - the loaded course file.
/// * `lecture` - the lecture id.
///
/// # Returns
///
/// The groups as `(objective id, its targets in this lecture)`, and the
/// leftovers: targets whose objective this lecture does not teach, and
/// objectives that have no targets and so stand as their own.
fn target_layout<'a>(
course: &'a CourseFile,
lecture: &str,
) -> (Vec<(&'a str, Vec<&'a str>)>, Vec<&'a str>) {
let targets = course.lecture_targets(lecture);
let mut groups: Vec<(&str, Vec<&str>)> = Vec::new();
for objective in course.lecture_objectives(lecture) {
let mine: Vec<&str> = course
.targets(objective)
.into_iter()
.filter(|t| targets.contains(t))
.collect();
if !mine.is_empty() {
groups.push((objective, mine));
}
}
let grouped: Vec<&str> = groups
.iter()
.flat_map(|(_, ts)| ts.iter().copied())
.collect();
let leftovers: Vec<&str> = targets
.into_iter()
.filter(|t| !grouped.contains(t))
.collect();
(groups, leftovers)
}
/// Whether a lecture's registry entries use the second tier.
///
/// # Arguments
///
/// * `course` - the loaded course file.
/// * `lecture` - the lecture id.
///
/// # Returns
///
/// `true` when any entry the lecture names is a target.
fn is_tiered(course: &CourseFile, lecture: &str) -> bool {
course
.lecture_entries(lecture)
.iter()
.any(|id| course.is_target(id))
}
/// The entries a lecture's pages number, in the order they print.
///
/// Numbering is a property of the *targets* page, because that is the tier a
/// reading serves: a section of a textbook backs a specific performance, not a
/// whole objective. An untiered lecture numbers its objectives instead, since
/// there each objective is its own target.
///
/// # Arguments
///
/// * `course` - the loaded course file.
/// * `lecture` - the lecture id.
///
/// # Returns
///
/// Registry ids in printed order.
fn numbered_targets(course: &CourseFile, lecture: &str) -> Vec<String> {
if !is_tiered(course, lecture) {
// Untiered page: level groups, in the order they print, which is the
// numbering this page has always had.
return by_level(course, &course.lecture_entries(lecture))
.into_iter()
.flat_map(|(_, members)| members)
.map(str::to_string)
.collect();
}
let (groups, leftovers) = target_layout(course, lecture);
groups
.into_iter()
.flat_map(|(_, targets)| targets)
.chain(leftovers)
.map(str::to_string)
.collect()
}
/// Groups ids by the level they are assessed up to, in taxonomy order.
///
/// # Arguments
///
/// * `course` - the loaded course file.
/// * `ids` - the ids to group.
///
/// # Returns
///
/// Non-empty groups, with whatever declares no ceiling last under `None`.
fn by_level<'a>(course: &CourseFile, ids: &[&'a str]) -> Vec<(Option<Level>, Vec<&'a str>)> {
let mut groups: Vec<(Option<Level>, Vec<&str>)> =
Level::ALL.iter().map(|l| (Some(*l), Vec::new())).collect();
groups.push((None, Vec::new()));
for id in ids {
let ceiling = course.effective_level_ceiling(id);
if let Some(slot) = groups.iter_mut().find(|(level, _)| *level == ceiling) {
slot.1.push(id);
}
}
groups.retain(|(_, members)| !members.is_empty());
groups
}
/// Renders the learning objectives for one lecture.
///
/// This is the short page: the four to eight claims the lecture is accountable
/// for, which is what a student reads before class and what an exam report will
/// classify. The performances behind them are [`targets_markdown`].
///
/// On a lecture whose entries are all objectives, the output is what it has
/// always been, grouped by level.
///
/// # Arguments
///
@@ -60,37 +182,100 @@ pub fn objectives_markdown(course: &CourseFile, lecture: &str, style: Style) ->
let mut out = String::from("## Learning objectives\n\n");
out.push_str("After this lecture, you should be able to do the following.\n\n");
// Levels in taxonomy order, then whatever declares no ceiling.
let mut groups: Vec<(Option<Level>, Vec<&str>)> =
Level::ALL.iter().map(|l| (Some(*l), Vec::new())).collect();
groups.push((None, Vec::new()));
for id in &ids {
let ceiling = course.learning_objectives[*id].level_ceiling;
if let Some(slot) = groups.iter_mut().find(|(level, _)| *level == ceiling) {
slot.1.push(id);
}
}
for (level, members) in &groups {
if members.is_empty() {
continue;
}
for (level, members) in by_level(course, &ids) {
if let Some(level) = level {
out.push_str(&format!("### {}\n\n", level.name()));
}
for id in members {
let text = course.objective_text(id);
out.push_str(&match style {
Style::Quarto => format!("(@) {text}\n"),
Style::Plain => format!("1. {text}\n"),
});
out.push_str(&bullet(&course.text_for(id), style));
}
out.push('\n');
}
Ok(out)
}
/// Renders the learning targets for one lecture, grouped under their objectives.
///
/// The long page: every performance an item on this material could be written
/// against. Grouping by objective rather than by level is deliberate, because
/// the objective is the grouping the student is being taught and the one a
/// report will use; level headings would cut across it and scatter one
/// objective's targets over three places.
///
/// The numbers here are the ones a reading's `_(T 4, 7)_` refers to, and both
/// come from [`numbered_targets`], so they cannot disagree.
///
/// # Arguments
///
/// * `course` - the loaded course file.
/// * `lecture` - the lecture id.
/// * `style` - which flavour to emit.
///
/// # Returns
///
/// The Markdown, ending in a newline. On a lecture with no targets declared,
/// the objectives stand as their own targets and the page is grouped by level
/// instead.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when the lecture id is not registered.
pub fn targets_markdown(course: &CourseFile, lecture: &str, style: Style) -> Result<String> {
course.lecture(lecture, "lecture page")?;
let mut out = String::from("## Learning targets\n\n");
out.push_str(
"Each objective above is met by the specific things below. Exam questions are \
written against these.\n\n",
);
if !is_tiered(course, lecture) {
for (level, members) in by_level(course, &course.lecture_entries(lecture)) {
if let Some(level) = level {
out.push_str(&format!("### {}\n\n", level.name()));
}
for id in members {
out.push_str(&bullet(&course.text_for(id), style));
}
out.push('\n');
}
return Ok(out);
}
let (groups, leftovers) = target_layout(course, lecture);
for (objective, targets) in groups {
out.push_str(&format!("### {}\n\n", course.text_for(objective)));
for target in targets {
out.push_str(&bullet(&course.text_for(target), style));
}
out.push('\n');
}
if !leftovers.is_empty() {
for id in leftovers {
out.push_str(&bullet(&course.text_for(id), style));
}
out.push('\n');
}
Ok(out)
}
/// One list item, numbered so a reading can point at it.
///
/// # Arguments
///
/// * `text` - the objective or target text.
/// * `style` - which flavour to emit.
///
/// # Returns
///
/// The line, ending in a newline.
fn bullet(text: &str, style: Style) -> String {
match style {
Style::Quarto => format!("(@) {text}\n"),
Style::Plain => format!("1. {text}\n"),
}
}
/// Renders the readings for one lecture.
///
/// # Arguments
@@ -112,9 +297,9 @@ pub fn readings_markdown(course: &CourseFile, lecture: &str, style: Style) -> Re
let lec = course.lecture(lecture, "lecture page")?;
// Positional numbers for this page, so `{lo-id}` in a note and the trailing
// `_(LO ...)_` agree with the objective list printed above them.
let order = course.lecture_objectives(lecture);
let number = |id: &str| order.iter().position(|o| *o == id).map(|i| i + 1);
// `_(T ...)_` agree with the targets page printed alongside.
let order = numbered_targets(course, lecture);
let number = |id: &str| order.iter().position(|o| o == id).map(|i| i + 1);
let mut out = String::from("## Readings\n\n");
for role in [ReadingRole::Assigned, ReadingRole::Supplemental] {
@@ -177,8 +362,8 @@ fn entry(
.flatten()
.map(|prose| {
course.expand_objective_refs(prose, |id| match number(id) {
Some(n) => format!("LO {n}"),
None => course.objective_text(id),
Some(n) => format!("T {n}"),
None => course.text_for(id),
})
})
.collect();
@@ -188,15 +373,12 @@ fn entry(
if !body.is_empty() {
out.push_str(&format!(": {}\n", body.join("\n")));
}
let mut numbers: Vec<usize> = reading
.objectives
.iter()
.filter_map(|o| number(o))
.collect();
let mut numbers: Vec<usize> =
reading.targets.iter().filter_map(|o| number(o)).collect();
numbers.sort_unstable();
if !numbers.is_empty() {
let list: Vec<String> = numbers.iter().map(|n| n.to_string()).collect();
out.push_str(&format!("<br>\n_(LO {})_\n", list.join(", ")));
out.push_str(&format!("<br>\n_(T {})_\n", list.join(", ")));
}
out.push('\n');
}
@@ -239,13 +421,14 @@ fn heading(reading: &Reading, key: &str, reference: &Reference, style: Style) ->
#[cfg(test)]
mod tests {
use super::*;
use crate::course::{Lecture, Objective, ReferenceRole};
use crate::course::{Lecture, Objective, ReferenceRole, Target};
/// A course with one lecture, two objectives, and one reference.
fn course() -> CourseFile {
let mut c = CourseFile::skeleton("BIOSC 1000", "Biochemistry", "2026f");
c.lectures.clear();
c.learning_objectives.clear();
c.learning_targets.clear();
c.references.insert(
"kuriyan2013molecules".into(),
@@ -280,7 +463,7 @@ mod tests {
reference: Some("kuriyan2013molecules".into()),
locator: Some("§6.1".into()),
path: Some("6/A/#1".into()),
objectives: vec!["lo-second".into(), "lo-first".into()],
targets: vec!["lo-second".into(), "lo-first".into()],
summary: Some("What a system is.".into()),
focus: Some("A worked instance of {lo-first}.".into()),
..Reading::default()
@@ -290,7 +473,7 @@ mod tests {
locator: Some("§1.9".into()),
path: Some("1/B/#9".into()),
role: ReadingRole::Supplemental,
objectives: vec!["lo-second".into()],
targets: vec!["lo-second".into()],
summary: Some("Background.".into()),
..Reading::default()
},
@@ -322,27 +505,27 @@ mod tests {
`KKW` [§6.1](https://example.org/kkw/6/A/#1)
: What a system is.
A worked instance of LO 1.
A worked instance of T 1.
<br>
_(LO 1, 2)_
_(T 1, 2)_
### Supplemental
`KKW` [§1.9](https://example.org/kkw/1/B/#9)
: Background.
<br>
_(LO 2)_
_(T 2)_
";
assert_eq!(md, expected);
}
#[test]
fn objective_numbers_follow_teaching_order_not_id_order() {
fn target_numbers_follow_teaching_order_not_id_order() {
// `lo-second` sorts first alphabetically and second by `order`.
let md = readings_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
assert!(md.contains("_(LO 1, 2)_"));
assert!(md.contains("A worked instance of LO 1."));
assert!(md.contains("_(T 1, 2)_"));
assert!(md.contains("A worked instance of T 1."));
}
#[test]
@@ -374,6 +557,98 @@ After this lecture, you should be able to do the following.
assert_eq!(md, expected);
}
/// The fixture course with its two entries moved into the target registry
/// under a new objective, which is the shape a migrated course has.
fn tiered_course() -> CourseFile {
let mut c = course();
c.learning_objectives.insert(
"lo-binding".into(),
Objective {
text: "Quantify binding.".into(),
lectures: vec!["L1.1".into()],
order: Some(0),
..objective_defaults()
},
);
// The readings cite `lo-second` and `lo-first`, so the ids are kept and
// only the registry they live in changes. A real migration renames them
// to the `t-` spelling and rewrites the citations with them.
for (id, order) in [("lo-first", 1), ("lo-second", 2)] {
let objective = c.learning_objectives.remove(id).expect("fixture");
c.learning_targets.insert(
id.into(),
Target {
text: objective.text,
objective: "lo-binding".into(),
lectures: objective.lectures,
order: Some(order),
level_ceiling: None,
prerequisites: Vec::new(),
tags: Vec::new(),
assessed: true,
},
);
}
c
}
#[test]
fn the_objectives_page_lists_claims_and_not_the_targets_under_them() {
let md = objectives_markdown(&tiered_course(), "L1.1", Style::Quarto).expect("renders");
let expected = "\
## Learning objectives
After this lecture, you should be able to do the following.
(@) Quantify binding.
";
assert_eq!(
md, expected,
"the page is the short list, not all forty rows"
);
}
#[test]
fn the_targets_page_groups_targets_under_their_objective() {
let md = targets_markdown(&tiered_course(), "L1.1", Style::Quarto).expect("renders");
let expected = "\
## Learning targets
Each objective above is met by the specific things below. Exam questions are \
written against these.
### Quantify binding.
(@) objective lo-first
(@) objective lo-second
";
assert_eq!(md, expected);
}
#[test]
fn reading_numbers_point_at_targets() {
// Numbering runs over the targets page, because that is the tier a
// reading serves.
let readings = readings_markdown(&tiered_course(), "L1.1", Style::Quarto).expect("renders");
assert!(readings.contains("_(T 1, 2)_"), "{readings}");
assert!(readings.contains("A worked instance of T 1."));
}
#[test]
fn an_untiered_lecture_renders_the_same_list_on_both_pages() {
// Where no targets are declared, each objective is its own target, and
// neither page silently drops anything.
let c = course();
let objectives = objectives_markdown(&c, "L1.1", Style::Quarto).expect("renders");
let targets = targets_markdown(&c, "L1.1", Style::Quarto).expect("renders");
for text in ["objective lo-first", "objective lo-second"] {
assert!(objectives.contains(text), "{objectives}");
assert!(targets.contains(text), "{targets}");
}
}
#[test]
fn an_objective_with_no_level_still_appears() {
// Ungrouped, at the end, rather than silently dropped.
+7 -7
View File
@@ -390,13 +390,13 @@ fn solution_body(out: &mut String, item: &Item) {
/// The `Tests:` line naming the objectives this item measures.
fn objectives_line(out: &mut String, item: &Item, course: &CourseFile) {
if item.learning_objectives.is_empty() {
if item.learning_targets.is_empty() {
return;
}
let texts: Vec<String> = item
.learning_objectives
.learning_targets
.iter()
.map(|id| course.objective_text(id))
.map(|id| course.text_for(id))
.collect();
out.push_str(&format!("**Tests:** {}\n\n", texts.join("; ")));
}
@@ -542,7 +542,7 @@ items:
status: draft
level: 1
stem: At constant pressure, the heat exchanged equals which quantity?
learning_objectives: [lo-enthalpy]
learning_targets: [lo-enthalpy]
options:
- { id: A, text: "the enthalpy change", correct: true, feedback_student: "Right: P dV work is folded into H." }
- { id: B, text: "the internal energy change", misconception: "ignores expansion work" }
@@ -556,7 +556,7 @@ items:
level: 2
format: open_response
stem: Explain why, at constant pressure, the heat exchanged equals the enthalpy change.
learning_objectives: [lo-enthalpy]
learning_targets: [lo-enthalpy]
solution:
model_answer: "At constant pressure the P dV expansion work is folded into H = U + PV, so q_p is the change in H."
rubric:
@@ -599,7 +599,7 @@ items:
bonus: false,
key: vec!["A".into()],
level: None,
learning_objectives: Vec::new(),
learning_targets: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
dropped_as: None,
@@ -613,7 +613,7 @@ items:
bonus: false,
key: Vec::new(),
level: None,
learning_objectives: Vec::new(),
learning_targets: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
dropped_as: None,
+4 -4
View File
@@ -1326,7 +1326,7 @@ items:
level: 2
format: single_best_answer
stem: "The heat at constant pressure equals a change in what?"
learning_objectives: [lo-enthalpy]
learning_targets: [lo-enthalpy]
options:
- { id: A, text: "Enthalpy, $\\Delta H$", correct: true }
- { id: B, text: "Internal energy, $\\Delta U$", misconception: "Constant-volume result." }
@@ -1335,7 +1335,7 @@ items:
level: 3
format: open_response
stem: "Show why $q_p = \\Delta H$."
learning_objectives: [lo-enthalpy]
learning_targets: [lo-enthalpy]
solution:
model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$."
"#,
@@ -1374,7 +1374,7 @@ items:
bonus: false,
key: vec!["A".into()],
level: None,
learning_objectives: Vec::new(),
learning_targets: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
dropped_as: None,
@@ -1388,7 +1388,7 @@ items:
bonus: false,
key: Vec::new(),
level: None,
learning_objectives: Vec::new(),
learning_targets: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
dropped_as: None,
+67 -22
View File
@@ -36,7 +36,7 @@ use crate::course::CourseFile;
use crate::date::Date;
use crate::irt::Fit;
use crate::students::{Cohort, Mastery, StudentSummary};
use crate::taxonomy::Level;
use crate::taxonomy::{Level, Tier};
/// What to include in a student report.
#[derive(Debug, Clone)]
@@ -150,24 +150,58 @@ pub fn student(
out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n");
for o in &summary.objectives {
let you = format!("{:.0}%", o.rate * 100.0);
out.push_str(&format!(
"| {} | {} | {} | {:.0}% | {} |\n",
o.status.symbol(),
escape_pipes(&o.text),
you,
o.cohort_rate * 100.0,
o.n_items
));
if o.tier == Tier::Objective {
// The classification, over every question that touched the
// objective.
let scope = if o.targets_total > 0 {
format!(
"{} ({} of {} targets tested)",
escape_pipes(&o.text),
o.targets_seen,
o.targets_total
)
} else {
escape_pipes(&o.text)
};
out.push_str(&format!(
"| {} | **{}** | {} | {:.0}% | {} |\n",
o.status.symbol(),
scope,
you,
o.cohort_rate * 100.0,
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_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
// saying so protects the student from over-reading a single data point.
let thin: Vec<&str> = summary
.objectives
.iter()
.filter(|o| o.status == Mastery::NotEnoughEvidence)
.filter(|o| o.tier == Tier::Objective && o.status == Mastery::NotEnoughEvidence)
.map(|o| o.text.as_str())
.collect();
if !thin.is_empty() {
@@ -236,7 +270,7 @@ pub fn student(
out.push_str("## Where to put your time\n\n");
out.push_str("In this order:\n\n");
for (i, id) in summary.focus.iter().take(4).enumerate() {
let text = course.objective_text(id);
let text = course.text_for(id);
out.push_str(&format!("{}. {}\n", i + 1, text));
}
out.push('\n');
@@ -250,10 +284,7 @@ pub fn student(
.map(|(id, _)| id.as_str())
.collect();
if !class_gaps.is_empty() {
let texts: Vec<String> = class_gaps
.iter()
.map(|id| course.objective_text(id))
.collect();
let texts: Vec<String> = class_gaps.iter().map(|id| course.text_for(id)).collect();
let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
out.push_str(&format!(
"Most of the class also struggled with {}, so expect it to come back in class. \
@@ -268,7 +299,7 @@ pub fn student(
.strengths
.iter()
.take(4)
.map(|id| course.objective_text(id))
.map(|id| course.text_for(id))
.collect();
let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
out.push_str(&format!("You have clearly got {}.\n\n", list(&refs)));
@@ -549,7 +580,7 @@ pub fn cohort(
for (id, rate) in &cohort.class_gaps {
out.push_str(&format!(
"| {} | {:.0}% |\n",
escape_pipes(&course.objective_text(id)),
escape_pipes(&course.text_for(id)),
rate * 100.0
));
}
@@ -576,7 +607,7 @@ pub fn cohort(
out.push('\n');
if let Some(bp) = &record.blueprint {
let drift = crate::select::check_blueprint(record);
let drift = crate::select::check_blueprint(record, course);
if !drift.is_empty() {
out.push_str("Blueprint drift:\n\n");
for d in &drift {
@@ -956,6 +987,10 @@ pub fn write_all_students(
/// Per-objective class rates as a compact table, for pasting into a syllabus
/// review or a curriculum committee document.
///
/// Objectives only. A committee document listing three hundred learning targets
/// is not read, and the target rates are the wrong number to put in front of one
/// anyway: each rests on one or two questions.
///
/// # Arguments
///
/// * `cohort` - the class.
@@ -965,7 +1000,7 @@ pub fn write_all_students(
///
/// A Markdown table.
pub fn objective_summary(cohort: &Cohort, course: &CourseFile) -> String {
let mut out = String::from("| Objective | Class rate |\n|:--|--:|\n");
let mut out = String::from("| Objective | Class rate | Items |\n|:--|--:|--:|\n");
let mut rows: Vec<(&String, &f64)> = cohort.objective_rates.iter().collect();
rows.sort_by(|a, b| {
a.1.partial_cmp(b.1)
@@ -973,10 +1008,20 @@ pub fn objective_summary(cohort: &Cohort, course: &CourseFile) -> String {
.then_with(|| a.0.cmp(b.0))
});
for (id, rate) in rows {
// Items behind the rate, because a rate without a denominator is what
// makes a committee table misleading.
let n = cohort
.students
.iter()
.flat_map(|s| s.objectives.iter())
.find(|o| &o.id == id)
.map(|o| o.n_items)
.unwrap_or(0);
out.push_str(&format!(
"| {} | {:.0}% |\n",
escape_pipes(&course.objective_text(id)),
rate * 100.0
"| {} | {:.0}% | {} |\n",
escape_pipes(&course.text_for(id)),
rate * 100.0,
n
));
}
out
+4 -4
View File
@@ -803,7 +803,7 @@ items:
level: 2
format: single_best_answer
stem: "The heat at constant pressure equals a change in what?"
learning_objectives: [lo-enthalpy]
learning_targets: [lo-enthalpy]
options:
- { id: A, text: "Enthalpy, $\\Delta H$", correct: true, feedback_student: "Right, $q_p = \\Delta H$." }
- { id: B, text: "Internal energy, $\\Delta U$", misconception: "Uses the constant-volume result.", explanation: "That holds only at constant volume." }
@@ -816,7 +816,7 @@ items:
level: 3
format: open_response
stem: "Show why $q_p = \\Delta H$."
learning_objectives: [lo-enthalpy]
learning_targets: [lo-enthalpy]
solution:
model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$."
explanation: |
@@ -863,7 +863,7 @@ items:
bonus: false,
key: vec!["A".into()],
level: None,
learning_objectives: Vec::new(),
learning_targets: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
dropped_as: None,
@@ -877,7 +877,7 @@ items:
bonus: false,
key: Vec::new(),
level: None,
learning_objectives: Vec::new(),
learning_targets: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
dropped_as: None,
+2 -2
View File
@@ -424,7 +424,7 @@ mod tests {
bonus: false,
key: vec!["A".into()],
level: None,
learning_objectives: Vec::new(),
learning_targets: Vec::new(),
credit_overrides: Default::default(),
dropped: true,
dropped_as: None,
@@ -438,7 +438,7 @@ mod tests {
bonus: false,
key: vec!["B".into()],
level: None,
learning_objectives: Vec::new(),
learning_targets: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
dropped_as: None,
+10 -10
View File
@@ -322,7 +322,7 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
value.insert_some("level", question.level.map(|l| Value::Int(l as i64)));
value.insert(
"objectives",
Value::Array(question.objectives.iter().map(|o| Value::str(o)).collect()),
Value::Array(question.objectives.iter().map(Value::str).collect()),
);
value.insert_some("correct", question.correct.map(Value::Bool));
value.insert("credit", Value::Float(question.credit));
@@ -350,7 +350,7 @@ pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> V
}
value.insert(
"taught-in",
Value::Array(question.taught_in.iter().map(|s| Value::str(s)).collect()),
Value::Array(question.taught_in.iter().map(Value::str).collect()),
);
value.insert(
"review",
@@ -741,7 +741,7 @@ pub fn cohort_value(diagnostic: &CohortDiagnostic, config: &RenderConfig) -> Val
out.insert(
"blueprint",
Value::Array(diagnostic.blueprint.iter().map(|s| Value::str(s)).collect()),
Value::Array(diagnostic.blueprint.iter().map(Value::str).collect()),
);
out.insert(
@@ -767,7 +767,7 @@ pub fn cohort_value(diagnostic: &CohortDiagnostic, config: &RenderConfig) -> Val
out.insert(
"warnings",
Value::Array(diagnostic.warnings.iter().map(|s| Value::str(s)).collect()),
Value::Array(diagnostic.warnings.iter().map(Value::str).collect()),
);
out
@@ -793,7 +793,7 @@ fn triage_value(row: &crate::diagnostic::TriageRow, content: bool) -> Value {
);
value.insert(
"taught-in",
Value::Array(row.taught_in.iter().map(|t| Value::str(t)).collect()),
Value::Array(row.taught_in.iter().map(Value::str).collect()),
);
value.insert_some("option", row.option.as_ref().map(Value::str));
value.insert_some("option-share", row.option_share.map(Value::Float));
@@ -845,7 +845,7 @@ fn cohort_question_value(question: &CohortQuestionRow, content: bool) -> Value {
value.insert_some("level", question.level.map(|l| Value::Int(l as i64)));
value.insert(
"objectives",
Value::Array(question.objectives.iter().map(|o| Value::str(o)).collect()),
Value::Array(question.objectives.iter().map(Value::str).collect()),
);
value.insert(
"objective-texts",
@@ -859,11 +859,11 @@ fn cohort_question_value(question: &CohortQuestionRow, content: bool) -> Value {
);
value.insert(
"taught-in",
Value::Array(question.taught_in.iter().map(|t| Value::str(t)).collect()),
Value::Array(question.taught_in.iter().map(Value::str).collect()),
);
value.insert(
"lectures",
Value::Array(question.lectures.iter().map(|l| Value::str(l)).collect()),
Value::Array(question.lectures.iter().map(Value::str).collect()),
);
value.insert("difficulty-band", Value::str(&question.difficulty_band));
value.insert(
@@ -876,7 +876,7 @@ fn cohort_question_value(question: &CohortQuestionRow, content: bool) -> Value {
value.insert("blank-rate", Value::Float(question.blank_rate));
value.insert(
"key",
Value::Array(question.key.iter().map(|k| Value::str(k)).collect()),
Value::Array(question.key.iter().map(Value::str).collect()),
);
value.insert(
"options",
@@ -899,7 +899,7 @@ fn cohort_question_value(question: &CohortQuestionRow, content: bool) -> Value {
);
value.insert(
"flags",
Value::Array(question.flags.iter().map(|f| Value::str(f)).collect()),
Value::Array(question.flags.iter().map(Value::str).collect()),
);
value.insert(
"notes",
+14 -18
View File
@@ -50,7 +50,7 @@ pub struct Payload {
pub form: FormInfo,
/// Counts and sums, so a template does not have to derive them.
pub totals: Totals,
/// Learning objectives referenced by the printed items, by id.
/// Learning objectives and targets referenced by the printed items, by id.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub objectives: BTreeMap<String, Objective>,
/// Shared stimuli, by id. Populated when the render config says stimuli are
@@ -268,9 +268,9 @@ pub struct Question {
/// letter. Omitted unless the config reveals the key.
#[serde(skip_serializing_if = "Option::is_none")]
pub credit_overrides: Option<BTreeMap<String, f64>>,
/// Learning objective ids.
/// Learning target ids.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec<String>,
pub learning_targets: Vec<String>,
/// Topic tags.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub topics: Vec<String>,
@@ -386,10 +386,10 @@ pub fn build(
.or_insert(0) += 1;
}
let objectives = if placement.learning_objectives.is_empty() {
item.learning_objectives.clone()
let objectives = if placement.learning_targets.is_empty() {
item.learning_targets.clone()
} else {
placement.learning_objectives.clone()
placement.learning_targets.clone()
};
if config.fields.objectives {
objective_ids.extend(objectives.iter().cloned());
@@ -467,7 +467,7 @@ pub fn build(
options,
key,
credit_overrides,
learning_objectives: if config.fields.objectives {
learning_targets: if config.fields.objectives {
objectives
} else {
Vec::new()
@@ -501,14 +501,10 @@ pub fn build(
let objectives = objective_ids
.into_iter()
.filter_map(|id| {
course.learning_objectives.get(&id).map(|o| {
(
id,
Objective {
text: markup::to_typst(&o.text),
unit: o.unit.clone(),
},
)
(course.is_objective(&id) || course.is_target(&id)).then(|| {
let text = markup::to_typst(&course.text_for(&id));
let unit = course.objective_unit(&id).map(str::to_string);
(id, Objective { text, unit })
})
})
.collect();
@@ -960,12 +956,12 @@ fn question_value(question: &Question, config: &RenderConfig) -> Value {
}),
);
if !question.learning_objectives.is_empty() {
if !question.learning_targets.is_empty() {
root.insert(
"learning-objectives",
"learning-targets",
Value::Array(
question
.learning_objectives
.learning_targets
.iter()
.map(|s| Value::str(s.as_str()))
.collect(),
+7 -3
View File
@@ -268,9 +268,13 @@ pub struct Placement {
/// The level as administered, denormalized so a record reads standalone.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<Level>,
/// Objectives as administered, denormalized for the same reason.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec<String>,
/// Targets as administered, denormalized for the same reason.
#[serde(
default,
alias = "learning_objectives",
skip_serializing_if = "Vec::is_empty"
)]
pub learning_targets: Vec<String>,
/// Credit awarded to non-keyed options after the fact, keyed by letter.
///
/// When item analysis or a student challenge leads you to credit a
+126 -33
View File
@@ -79,7 +79,7 @@ pub struct BankMeta {
/// What a bank is scoped to.
///
/// A bank may be scoped by lecture, by objective, by topic, or by none of them.
/// A bank may be scoped by lecture, by learning target, by topic, or by none of them.
/// Declaring the scope is what lets the catalog report *gaps*: it can only tell
/// you that lecture 12 has no Apply-level items if it knows lecture 12 is
/// supposed to be covered here.
@@ -89,9 +89,13 @@ pub struct Scope {
/// Lectures this bank draws from.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lectures: Vec<String>,
/// Objectives this bank is responsible for covering.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec<String>,
/// Learning targets this bank is responsible for covering.
#[serde(
default,
alias = "learning_objectives",
skip_serializing_if = "Vec::is_empty"
)]
pub learning_targets: Vec<String>,
/// Units this bank belongs to.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub units: Vec<String>,
@@ -254,9 +258,9 @@ impl BankFile {
issues.push(format!("bank.scope: unknown lecture `{lec}`"));
}
}
for lo in &self.bank.scope.learning_objectives {
if !c.learning_objectives.contains_key(lo) {
issues.push(format!("bank.scope: unknown learning objective `{lo}`"));
for target in &self.bank.scope.learning_targets {
if !c.is_target(target) && !c.is_objective(target) {
issues.push(format!("bank.scope: unknown learning target `{target}`"));
}
}
}
@@ -563,8 +567,8 @@ fn validate_item(
if it.cognitive_process.is_none() {
issues.push("approved items must declare a cognitive_process".into());
}
if it.learning_objectives.is_empty() {
issues.push("approved items must reference at least one learning objective".into());
if it.learning_targets.is_empty() {
issues.push("approved items must reference at least one learning target".into());
}
if it.sources.is_empty() {
issues.push("approved items must cite at least one source".into());
@@ -590,26 +594,64 @@ fn validate_item(
// --- cross-file references ----
if let Some(c) = course {
for lo in &it.learning_objectives {
match c.learning_objectives.get(lo) {
None => issues.push(format!("unknown learning objective `{lo}`")),
Some(obj) => {
if let Some(ceiling) = obj.level_ceiling {
if it.level > ceiling {
issues.push(format!(
"level {} exceeds the ceiling {} declared for objective `{lo}`",
it.level.code(),
ceiling.code()
));
}
}
if !obj.assessed {
issues.push(format!(
"objective `{lo}` is marked `assessed: false` but this item measures it"
));
}
for tag in &it.learning_targets {
let is_target = c.is_target(tag);
if !is_target && !c.is_objective(tag) {
issues.push(format!("unknown learning target `{tag}`"));
continue;
}
// Items are tagged at the target tier. Tagging an objective that has
// targets would put the item in that objective's denominator without
// recording which performance the question actually asked for, and
// that record is what a report drills into and what coverage
// analysis counts. An objective with no targets stands as its own.
let its_targets = c.targets(tag);
if !its_targets.is_empty() {
issues.push(format!(
"`{tag}` is an objective with {} learning target(s); tag the specific \
target this item measures instead",
its_targets.len()
));
}
// The ceiling may be inherited from the objective, so a target that
// declares none of its own is still bounded.
if let Some(ceiling) = c.effective_level_ceiling(tag) {
if it.level > ceiling {
let declares_its_own = if is_target {
c.learning_targets
.get(tag)
.is_some_and(|t| t.level_ceiling.is_some())
} else {
true
};
let source = if declares_its_own {
format!("declared for `{tag}`")
} else {
format!(
"inherited by `{tag}` from its objective `{}`",
c.objective_for(tag)
)
};
issues.push(format!(
"level {} exceeds the ceiling {} {source}",
it.level.code(),
ceiling.code()
));
}
}
if !c.is_assessed(tag) {
let parked_above =
is_target && c.learning_targets.get(tag).is_some_and(|t| t.assessed);
let which = if parked_above {
format!(
"its objective `{}` is marked `assessed: false`",
c.objective_for(tag)
)
} else {
format!("`{tag}` is marked `assessed: false`")
};
issues.push(format!("{which} but this item measures it"));
}
}
for s in &it.sources {
if !c.lectures.contains_key(&s.lecture) {
@@ -707,7 +749,7 @@ mod tests {
level: 2
format: open_response
cognitive_process: explain
learning_objectives: [lo-x]
learning_targets: [lo-x]
sources: [{ lecture: L1.1 }]
design: { rationale: r }
stem: Explain the first law.
@@ -827,7 +869,7 @@ mod tests {
let issues = b.validate(None);
for want in [
"cognitive_process",
"learning objective",
"learning target",
"source",
"design block",
] {
@@ -914,7 +956,7 @@ learning_objectives:
status: draft
level: 4
stem: s
learning_objectives: [lo-known, lo-unknown]
learning_targets: [lo-known, lo-unknown]
sources: [{ lecture: L99 }]
options:
- { id: A, text: a, correct: true }
@@ -925,7 +967,7 @@ learning_objectives:
assert!(
issues
.iter()
.any(|i| i.contains("unknown learning objective `lo-unknown`"))
.any(|i| i.contains("unknown learning target `lo-unknown`"))
);
assert!(issues.iter().any(|i| i.contains("unknown lecture `L99`")));
assert!(
@@ -934,6 +976,57 @@ learning_objectives:
);
}
#[test]
fn an_item_must_tag_a_target_not_an_objective_that_has_them() {
let course: CourseFile = serde_yaml_ng::from_str(
r#"
course: { code: X, title: Y, term: Z }
learning_objectives:
lo-binding: { text: Quantify binding., level_ceiling: 3 }
lo-solo: { text: An objective with no targets. }
learning_targets:
t-kd: { text: Write the expression., objective: lo-binding }
"#,
)
.unwrap();
let b = bank(
r#"
- id: q-a-001
status: draft
level: 1
stem: s
learning_targets: [lo-binding]
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
- id: q-a-002
status: draft
level: 1
stem: s
learning_targets: [t-kd, lo-solo]
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
"#,
);
let issues = b.validate(Some(&course));
assert!(
issues
.iter()
.any(|i| i.contains("`lo-binding` is an objective with 1 learning target(s)")),
"got {issues:?}"
);
// A target is fine, and so is an objective that has no targets: it
// stands as its own, which is what lets a course migrate a unit at a
// time.
assert!(
!issues
.iter()
.any(|i| i.contains("`t-kd`") || i.contains("`lo-solo`")),
"got {issues:?}"
);
}
#[test]
fn history_versions_must_increase() {
let b = bank(
@@ -964,7 +1057,7 @@ learning_objectives:
level: 1
cognitive_process: recall
stem: s
learning_objectives: [lo]
learning_targets: [lo]
sources: [{ lecture: L1 }]
design: { expected_difficulty: 0.8 }
options:
@@ -983,7 +1076,7 @@ learning_objectives:
cognitive_process: generate
bonus: true
stem: s
learning_objectives: [lo]
learning_targets: [lo]
sources: [{ lecture: L1 }]
design: { expected_difficulty: 0.3 }
options:
+168 -33
View File
@@ -26,7 +26,7 @@ use crate::course::CourseFile;
use crate::error::{Error, Result};
use crate::item::Item;
use crate::layout::Layout;
use crate::taxonomy::{Level, Status};
use crate::taxonomy::{Level, Status, Tier};
use crate::yaml;
/// One item plus everything needed to locate it again.
@@ -321,19 +321,19 @@ impl Catalog {
out
}
/// Items that measure a given objective.
/// Items tagged with a given learning target.
///
/// # Arguments
///
/// * `objective` - the objective id.
/// * `target` - the target id.
///
/// # Returns
///
/// Matching entries.
pub fn by_objective(&self, objective: &str) -> Vec<&Entry> {
pub fn by_target(&self, target: &str) -> Vec<&Entry> {
self.entries
.iter()
.filter(|e| e.item.learning_objectives.iter().any(|o| o == objective))
.filter(|e| e.item.learning_targets.iter().any(|t| t == target))
.collect()
}
@@ -384,55 +384,124 @@ impl Catalog {
out
}
/// Builds the coverage report.
/// Items that measure a given objective, through any of its targets.
///
/// # Arguments
///
/// * `objective` - the objective id.
///
/// # Returns
///
/// One row per assessed objective plus a list of course-wide gaps.
/// Entries tagged with the objective itself or with any of its targets,
/// each appearing once even when it is tagged with two of them.
pub fn by_objective(&self, objective: &str) -> Vec<&Entry> {
self.entries
.iter()
.filter(|e| {
e.item
.learning_targets
.iter()
.any(|t| t == objective || self.course.objective_for(t) == objective)
})
.collect()
}
/// Builds the coverage report.
///
/// Rows cover both tiers, because the two answer different questions. An
/// objective row answers "can I build an exam that reports on this
/// objective", and aggregates every item under it. A target row answers
/// "which specific things have I written items for", which is the
/// authoring queue, and a target with no items is the most common and least
/// visible hole in a bank: the objective looks well covered while a third of
/// what it claims has never been asked.
///
/// # Returns
///
/// One row per assessed entry plus a list of course-wide gaps.
pub fn coverage(&self) -> Coverage {
let mut rows = Vec::new();
for id in self.course.objectives_in_order() {
let obj = &self.course.learning_objectives[&id];
if !obj.assessed {
for id in self.course.registry_in_order() {
if !self.course.is_objective(&id) && !self.course.is_target(&id) {
continue;
}
let items = self.by_objective(&id);
if !self.course.is_assessed(&id) {
continue;
}
let targets = self.course.targets(&id);
let tier = if self.course.is_objective(&id) {
Tier::Objective
} else {
Tier::Target
};
// An objective's pool is everything under it; a target's is what is
// tagged to it directly. An objective with no targets is its own
// target, so the two agree there.
let items = if targets.is_empty() {
self.by_target(&id)
} else {
self.by_objective(&id)
};
let usable: Vec<&&Entry> = items.iter().filter(|e| e.item.is_assemblable()).collect();
let mut levels: BTreeSet<Level> = BTreeSet::new();
for e in &usable {
levels.insert(e.item.level);
}
let targets_covered = targets
.iter()
.filter(|target| {
self.by_target(target)
.iter()
.any(|e| e.item.is_assemblable())
})
.count();
rows.push(CoverageRow {
objective: id.clone(),
text: obj.text.clone(),
unit: obj.unit.clone(),
id: id.clone(),
text: self.course.text_for(&id),
tier,
objective: self
.course
.is_target(&id)
.then(|| self.course.objective_for(&id).to_string()),
unit: self.course.objective_unit(&id).map(str::to_string),
targets: targets.len(),
targets_covered,
total: items.len(),
assemblable: usable.len(),
max_level: levels.iter().next_back().copied(),
levels: levels.into_iter().collect(),
ceiling: obj.level_ceiling,
ceiling: self.course.effective_level_ceiling(&id),
});
}
let mut gaps = Vec::new();
for row in &rows {
// Gaps are raised against the tier that can be acted on. "No items
// at all" is worth saying about a target, because writing one is the
// fix. "Resting on a single item" is worth saying about an
// objective, because a target resting on one item is the normal and
// intended case, and flagging two hundred of them would bury the
// rows that matter.
if row.total == 0 {
gaps.push(Gap::Uncovered(row.objective.clone()));
gaps.push(Gap::Uncovered(row.id.clone()));
} else if row.assemblable == 0 {
gaps.push(Gap::NoApprovedItems(row.objective.clone()));
} else if row.assemblable == 1 {
gaps.push(Gap::SingleItem(row.objective.clone()));
gaps.push(Gap::NoApprovedItems(row.id.clone()));
} else if row.assemblable == 1 && row.tier == Tier::Objective {
gaps.push(Gap::SingleItem(row.id.clone()));
}
// An objective assessed only at the recall level is the most common
// and most consequential blind spot: it looks covered in a count and
// is not covered in fact.
if row.assemblable > 0 && row.max_level == Some(Level::Remember) {
if row.tier == Tier::Objective
&& row.assemblable > 0
&& row.max_level == Some(Level::Remember)
{
if let Some(ceiling) = row.ceiling {
if ceiling > Level::Remember {
gaps.push(Gap::RecallOnly(row.objective.clone()));
gaps.push(Gap::RecallOnly(row.id.clone()));
}
} else {
gaps.push(Gap::RecallOnly(row.objective.clone()));
gaps.push(Gap::RecallOnly(row.id.clone()));
}
}
}
@@ -451,7 +520,7 @@ impl Catalog {
// Items with no objective at all cannot appear in any student report.
for e in &self.entries {
if e.item.learning_objectives.is_empty() && e.item.is_assemblable() {
if e.item.learning_targets.is_empty() && e.item.is_assemblable() {
gaps.push(Gap::ItemWithoutObjective(e.uid.clone()));
}
}
@@ -463,21 +532,35 @@ impl Catalog {
/// The coverage report.
#[derive(Debug, Clone)]
pub struct Coverage {
/// One row per assessed objective.
/// One row per assessed registry entry, objectives first with their targets
/// following each.
pub rows: Vec<CoverageRow>,
/// Course-wide gaps worth acting on.
pub gaps: Vec<Gap>,
}
/// Coverage of one objective.
/// Coverage of one registry entry, at either tier.
#[derive(Debug, Clone)]
pub struct CoverageRow {
/// The objective id.
pub objective: String,
/// The objective text.
/// The registry id.
pub id: String,
/// Its text.
pub text: String,
/// The unit it belongs to.
/// Whether this is an objective, whose counts aggregate every item under it,
/// or a target, whose counts are its own items.
pub tier: Tier,
/// The objective this row sits under, for a target.
pub objective: Option<String>,
/// The unit it belongs to, inherited from the objective when not declared.
pub unit: Option<String>,
/// Targets in the registry, for an objective row.
pub targets: usize,
/// Targets with at least one usable item.
///
/// The number to look at when an objective looks well covered: twenty items
/// spread over four of its nine targets is a different bank from twenty
/// items spread over all nine.
pub targets_covered: usize,
/// Items referencing it, at any status.
pub total: usize,
/// Items that could actually be used.
@@ -486,16 +569,16 @@ pub struct CoverageRow {
pub max_level: Option<Level>,
/// Every level assessed.
pub levels: Vec<Level>,
/// The declared ceiling, when set.
/// The ceiling in force, inherited from the objective when not declared.
pub ceiling: Option<Level>,
}
/// A specific, actionable hole in the item pool.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Gap {
/// An assessed objective with no items at all.
/// An assessed objective or target with no items at all.
Uncovered(String),
/// An objective whose items are all drafts or retired.
/// An entry whose items are all drafts or retired.
NoApprovedItems(String),
/// An objective resting on a single item, so one bad item hides it entirely.
SingleItem(String),
@@ -606,7 +689,7 @@ items:
level: 1
cognitive_process: recall
stem: What is x?
learning_objectives: [lo-covered]
learning_targets: [lo-covered]
sources: [{ lecture: L01 }]
design: { expected_difficulty: 0.8 }
options:
@@ -672,6 +755,58 @@ items:
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn coverage_aggregates_targets_and_names_the_untested_ones() {
let dir = tmp("tiered-coverage");
std::fs::create_dir_all(dir.join("banks")).unwrap();
std::fs::write(
dir.join("course.yaml"),
r#"
course: { code: TEST 101, title: Testing, term: Fall 2026 }
lectures:
L01: { title: One }
learning_objectives:
lo-binding: { text: Quantify binding., lectures: [L01], order: 1, level_ceiling: 3 }
learning_targets:
t-kd: { text: Write the expression., objective: lo-binding, order: 1 }
t-plot: { text: Read a plot., objective: lo-binding, order: 2 }
"#,
)
.unwrap();
// Two items, both on the same target.
let bank = APPROVED.replace("lo-covered", "t-kd").replace(
" - { id: B, text: wrong }\n",
" - { id: B, text: wrong }\n - id: q-x-002\n status: approved\n level: 3\n \
cognitive_process: implement\n stem: And again?\n learning_targets: [t-kd]\n \
sources: [{ lecture: L01 }]\n design: { expected_difficulty: 0.5 }\n options:\n \
- { id: A, text: right, correct: true }\n - { id: B, text: wrong }\n",
);
write_bank(&dir, "b1.yaml", &bank);
let cat = Catalog::load(&dir).unwrap();
let cov = cat.coverage();
let objective = cov
.rows
.iter()
.find(|r| r.id == "lo-binding")
.expect("the objective row");
assert_eq!(objective.tier, Tier::Objective);
assert_eq!(
objective.assemblable, 2,
"the objective aggregates its targets"
);
assert_eq!(
(objective.targets_covered, objective.targets),
(1, 2),
"two items, but only one of the two targets has been asked about"
);
// Resting on one item is the normal case for a target, so it is not a
// gap; never having been asked about at all is.
assert!(!cov.gaps.contains(&Gap::SingleItem("t-kd".into())));
assert!(cov.gaps.contains(&Gap::Uncovered("t-plot".into())));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn recall_only_respects_a_recall_ceiling() {
let dir = tmp("ceiling");
+993 -81
View File
File diff suppressed because it is too large Load Diff
+18 -8
View File
@@ -102,9 +102,19 @@ pub struct Item {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub solution: Option<Solution>,
/// Objectives this item measures, as ids into the course registry.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec<String>,
/// The learning targets this item measures, as ids into the course
/// registry.
///
/// Targets rather than objectives: an item measures one specific
/// performance, and recording which one is what lets a report explain an
/// objective's result instead of only stating it. The objective follows from
/// the target, so it is never recorded twice.
#[serde(
default,
alias = "learning_objectives",
skip_serializing_if = "Vec::is_empty"
)]
pub learning_targets: Vec<String>,
/// Where the material was taught.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -114,7 +124,7 @@ pub struct Item {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub topics: Vec<String>,
/// Item ids or objective ids a student needs before this is fair.
/// Item ids or registry ids a student needs before this is fair.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prerequisites: Vec<String>,
@@ -671,7 +681,7 @@ impl Item {
/// the only way to get a half-built `Item` is deliberately.
///
/// The result is `Status::Draft` and deliberately will not pass
/// [`Item::is_assemblable`] — it still needs learning objectives, sources, and
/// [`Item::is_assemblable`] — it still needs learning targets, sources, and
/// a cognitive process before it can be drawn onto an assessment.
///
/// # Arguments
@@ -703,7 +713,7 @@ impl Item {
stem: stem.to_string(),
options,
solution: None,
learning_objectives: Vec::new(),
learning_targets: Vec::new(),
sources: Vec::new(),
topics: Vec::new(),
prerequisites: Vec::new(),
@@ -798,7 +808,7 @@ impl Item {
/// A content fingerprint over everything that affects what a student sees.
///
/// Metadata deliberately does not contribute: retagging an objective must not
/// Metadata deliberately does not contribute: retagging a target must not
/// invalidate pooled statistics, but rewording an option must.
///
/// # Returns
@@ -964,7 +974,7 @@ options:
let base = item(MINIMAL);
let mut retagged = base.clone();
retagged.topics = vec!["kinetics".into()];
retagged.learning_objectives = vec!["lo-a".into()];
retagged.learning_targets = vec!["lo-a".into()];
retagged.author = Some("someone".into());
assert_eq!(
base.fingerprint(),
+6 -6
View File
@@ -161,7 +161,7 @@ pub struct SealedItem {
pub level: Option<Level>,
/// Objectives as administered.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec<String>,
pub learning_targets: Vec<String>,
/// The keyed letters in the bank's own lettering, before any shuffle.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub key: Vec<String>,
@@ -491,10 +491,10 @@ fn sealed_item(
.unwrap_or_else(|| item.points(default_points)),
bonus: placement.bonus,
level: placement.level.or(Some(item.level)),
learning_objectives: if placement.learning_objectives.is_empty() {
item.learning_objectives.clone()
learning_targets: if placement.learning_targets.is_empty() {
item.learning_targets.clone()
} else {
placement.learning_objectives.clone()
placement.learning_targets.clone()
},
key: if placement.key.is_empty() {
item.key_letters()
@@ -903,7 +903,7 @@ impl SealFile {
),
));
}
if was.learning_objectives != now.learning_objectives {
if was.learning_targets != now.learning_targets {
out.push(Drift::new(
Severity::Low,
"objectives-retagged",
@@ -1160,7 +1160,7 @@ mod tests {
points: 1.0,
bonus: false,
level: Some(Level::Remember),
learning_objectives: vec!["lo-a".into()],
learning_targets: vec!["lo-a".into()],
key: vec!["B".into()],
stem: Some("Stem.".into()),
options: vec![
+38
View File
@@ -9,6 +9,44 @@ use std::fmt;
use serde::{Deserialize, Serialize};
/// Which tier of the objective registry an entry or a reported row belongs to.
///
/// The two words come from the assessment literature and name two different
/// jobs, not two sizes of the same thing. A **learning objective** is what a
/// syllabus lists and what a report classifies as met: the unit a claim is made
/// about. A **learning target** is the specific performance an item is written
/// against and tagged to: what a student aims at in one class, and the evidence
/// a claim about an objective rests on.
///
/// It lives here rather than beside the registry because every layer that
/// reports needs it, and a `bool` named for one of the two tiers leaves the
/// reader guessing which way round it points.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Tier {
/// A learning objective: the tier a mastery claim is made about.
Objective,
/// A learning target: the tier items are tagged to, and evidence for the
/// objective above it.
Target,
}
impl Tier {
/// The snake_case token used in YAML and in flat storage.
pub fn as_str(self) -> &'static str {
match self {
Tier::Objective => "objective",
Tier::Target => "target",
}
}
}
impl fmt::Display for Tier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// Cognitive demand, following the revised Bloom taxonomy.
///
/// Serialized as the integers 1 through 5 so YAML reads `level: 3`. The derived