diff --git a/src/analysis/classical.rs b/src/analysis/classical.rs index 319ed38..468ba36 100644 --- a/src/analysis/classical.rs +++ b/src/analysis/classical.rs @@ -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, diff --git a/src/analysis/diagnostic.rs b/src/analysis/diagnostic.rs index a30b437..79f8601 100644 --- a/src/analysis/diagnostic.rs +++ b/src/analysis/diagnostic.rs @@ -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 Vec> = 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 = 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); diff --git a/src/analysis/students.rs b/src/analysis/students.rs index c688252..45b25d5 100644 --- a/src/analysis/students.rs +++ b/src/analysis/students.rs @@ -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, + /// 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, - /// The objectives involved. - pub learning_objectives: Vec, + /// The learning targets the question measured. + pub learning_targets: Vec, /// The misconception the chosen distractor was written to detect. pub misconception: Option, /// 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, - /// 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, + /// 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, /// Class rate per level. pub level_rates: BTreeMap, @@ -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, @@ -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::>()); + let target_rates = rates_by_target(&set.rows.iter().collect::>()); + let objective_rates = rates_by_objective(&set.rows.iter().collect::>(), course); let level_rates = rates_by_level(&set.rows.iter().collect::>()); // 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 = 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 = 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 = 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 = focus_pairs - .iter() - .map(|(o, _)| o.objective.clone()) - .collect(); + let focus: Vec = 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 = 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::>() .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 { - counts_by_objective(rows) +/// The rate for each target mentioned. +pub fn rates_by_target(rows: &[&Response]) -> BTreeMap { + 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 { .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 { /// /// # Returns /// -/// `(item count, total credit)` per objective. -pub fn counts_by_objective(rows: &[&Response]) -> BTreeMap { +/// `(item count, total credit)` per target. +pub fn counts_by_target(rows: &[&Response]) -> BTreeMap { let mut out: BTreeMap = 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 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 { + let mut out: BTreeMap = 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 { + 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, diff --git a/src/authoring/jsonschema.rs b/src/authoring/jsonschema.rs index 3788509..ce3dc32 100644 --- a/src/authoring/jsonschema.rs +++ b/src/authoring/jsonschema.rs @@ -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 \ diff --git a/src/authoring/select.rs b/src/authoring/select.rs index 51d7264..cd4c983 100644 --- a/src/authoring/select.rs +++ b/src/authoring/select.rs @@ -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 { /// # 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 { +pub fn check_blueprint(record: &AssessmentFile, course: &CourseFile) -> Vec { 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 { 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 { 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 { /// /// # Returns /// -/// The objective ids, deduplicated. -pub fn covered_objectives(record: &AssessmentFile) -> BTreeSet { +/// The target ids, deduplicated. +pub fn covered_targets(record: &AssessmentFile) -> BTreeSet { 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 { + 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!["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")); } diff --git a/src/cli.rs b/src/cli.rs index ad7b520..a01ee2c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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, }, - /// 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, }, - /// 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, + }, + /// 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, }, @@ -799,4 +813,4 @@ mod tests { fn the_cli_rejects_an_unknown_subcommand() { assert!(Cli::try_parse_from(["coursebank", "frobnicate"]).is_err()); } -} \ No newline at end of file +} diff --git a/src/commands/analysis.rs b/src/commands/analysis.rs index 213fc3b..e1d1554 100644 --- a/src/commands/analysis.rs +++ b/src/commands/analysis.rs @@ -426,7 +426,7 @@ pub(crate) fn analyze(cli: &Cli, sub: &AnalyzeCommand) -> Result { println!( " {:>4.0}% {}", rate * 100.0, - catalog.course.objective_text(objective) + catalog.course.text_for(objective) ); } } @@ -782,4 +782,4 @@ fn warn_about_drift( eprintln!(" {}", finding.message); } } -} \ No newline at end of file +} diff --git a/src/commands/banks.rs b/src/commands/banks.rs index ee0b84c..256fe24 100644 --- a/src/commands/banks.rs +++ b/src/commands/banks.rs @@ -184,7 +184,7 @@ pub(crate) fn assemble(cli: &Cli, args: &AssembleArgs) -> Result { 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 { diff --git a/src/commands/lectures.rs b/src/commands/lectures.rs index 82f6e64..61f6e15 100644 --- a/src/commands/lectures.rs +++ b/src/commands/lectures.rs @@ -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 { 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 { 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 { let ids: Vec = 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 Result { 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 { 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()) diff --git a/src/data/canvas.rs b/src/data/canvas.rs index 7f6a764..5e94a62 100644 --- a/src/data/canvas.rs +++ b/src/data/canvas.rs @@ -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, diff --git a/src/data/decode.rs b/src/data/decode.rs index 84bdc93..16534c1 100644 --- a/src/data/decode.rs +++ b/src/data/decode.rs @@ -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, diff --git a/src/data/gradescope.rs b/src/data/gradescope.rs index 671c0e8..b4b497d 100644 --- a/src/data/gradescope.rs +++ b/src/data/gradescope.rs @@ -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, diff --git a/src/data/responses.rs b/src/data/responses.rs index 5b03952..53f0684 100644 --- a/src/data/responses.rs +++ b/src/data/responses.rs @@ -101,7 +101,7 @@ pub struct Response { /// The item's level, denormalized so analysis need not carry the catalog. pub level: Option, /// The item's learning objectives, denormalized for per-objective mastery. - pub learning_objectives: Vec, + pub learning_targets: Vec, /// The item's topics, denormalized. pub topics: Vec, /// 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, diff --git a/src/data/store.rs b/src/data/store.rs index c851b2e..ffb5cd9 100644 --- a/src/data/store.rs +++ b/src/data/store.rs @@ -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); diff --git a/src/data/store_parquet.rs b/src/data/store_parquet.rs index 0343cd9..51ff891 100644 --- a/src/data/store_parquet.rs +++ b/src/data/store_parquet.rs @@ -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 { 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> { 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> { 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); diff --git a/src/export/lecture.rs b/src/export/lecture.rs index cc18850..4158c67 100644 --- a/src/export/lecture.rs +++ b/src/export/lecture.rs @@ -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 { + 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, Vec<&'a str>)> { + let mut groups: Vec<(Option, 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, 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 { + 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 = reading - .objectives - .iter() - .filter_map(|o| number(o)) - .collect(); + let mut numbers: Vec = + reading.targets.iter().filter_map(|o| number(o)).collect(); numbers.sort_unstable(); if !numbers.is_empty() { let list: Vec = numbers.iter().map(|n| n.to_string()).collect(); - out.push_str(&format!("
\n_(LO {})_\n", list.join(", "))); + out.push_str(&format!("
\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.
-_(LO 1, 2)_ +_(T 1, 2)_ ### Supplemental `KKW` [§1.9](https://example.org/kkw/1/B/#9) : Background.
-_(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. diff --git a/src/export/practice.rs b/src/export/practice.rs index b3a7a4c..c92f267 100644 --- a/src/export/practice.rs +++ b/src/export/practice.rs @@ -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 = 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, diff --git a/src/export/qti.rs b/src/export/qti.rs index 4aaa520..bc0b331 100644 --- a/src/export/qti.rs +++ b/src/export/qti.rs @@ -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, diff --git a/src/export/report.rs b/src/export/report.rs index 5c10fc0..64480d8 100644 --- a/src/export/report.rs +++ b/src/export/report.rs @@ -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 = class_gaps - .iter() - .map(|id| course.objective_text(id)) - .collect(); + let texts: Vec = 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 diff --git a/src/export/site.rs b/src/export/site.rs index 9e2608e..986d102 100644 --- a/src/export/site.rs +++ b/src/export/site.rs @@ -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, diff --git a/src/export/typst.rs b/src/export/typst.rs index 014c669..92625a1 100644 --- a/src/export/typst.rs +++ b/src/export/typst.rs @@ -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, diff --git a/src/export/typst/config.rs b/src/export/typst/config.rs index bd02870..01406d8 100644 --- a/src/export/typst/config.rs +++ b/src/export/typst/config.rs @@ -869,4 +869,4 @@ mod tests { assert_eq!(file.resolve(Variant::Exam).reveal, Reveal::Nothing); assert_eq!(file.resolve(Variant::Key).reveal, Reveal::Everything); } -} \ No newline at end of file +} diff --git a/src/export/typst/diagnostic.rs b/src/export/typst/diagnostic.rs index 73ce262..74d65f4 100644 --- a/src/export/typst/diagnostic.rs +++ b/src/export/typst/diagnostic.rs @@ -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", @@ -1236,4 +1236,4 @@ mod tests { assert!(text.contains("reliability")); assert!(!text.contains("stem")); } -} \ No newline at end of file +} diff --git a/src/export/typst/payload.rs b/src/export/typst/payload.rs index 656fb5e..68171a2 100644 --- a/src/export/typst/payload.rs +++ b/src/export/typst/payload.rs @@ -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, /// 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>, - /// Learning objective ids. + /// Learning target ids. #[serde(skip_serializing_if = "Vec::is_empty")] - pub learning_objectives: Vec, + pub learning_targets: Vec, /// Topic tags. #[serde(skip_serializing_if = "Vec::is_empty")] pub topics: Vec, @@ -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(), diff --git a/src/model/assessment.rs b/src/model/assessment.rs index 51ec04c..455e639 100644 --- a/src/model/assessment.rs +++ b/src/model/assessment.rs @@ -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, - /// Objectives as administered, denormalized for the same reason. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub learning_objectives: Vec, + /// Targets as administered, denormalized for the same reason. + #[serde( + default, + alias = "learning_objectives", + skip_serializing_if = "Vec::is_empty" + )] + pub learning_targets: Vec, /// Credit awarded to non-keyed options after the fact, keyed by letter. /// /// When item analysis or a student challenge leads you to credit a diff --git a/src/model/bank.rs b/src/model/bank.rs index 9efd32f..90e3cac 100644 --- a/src/model/bank.rs +++ b/src/model/bank.rs @@ -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, - /// Objectives this bank is responsible for covering. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub learning_objectives: Vec, + /// Learning targets this bank is responsible for covering. + #[serde( + default, + alias = "learning_objectives", + skip_serializing_if = "Vec::is_empty" + )] + pub learning_targets: Vec, /// Units this bank belongs to. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub units: Vec, @@ -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: diff --git a/src/model/catalog.rs b/src/model/catalog.rs index 49004e1..47f34ff 100644 --- a/src/model/catalog.rs +++ b/src/model/catalog.rs @@ -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 = 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, /// Course-wide gaps worth acting on. pub gaps: Vec, } -/// 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, + /// The unit it belongs to, inherited from the objective when not declared. pub unit: Option, + /// 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, /// Every level assessed. pub levels: Vec, - /// The declared ceiling, when set. + /// The ceiling in force, inherited from the objective when not declared. pub ceiling: Option, } /// 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"); diff --git a/src/model/course.rs b/src/model/course.rs index 5e9dec4..c9f1017 100644 --- a/src/model/course.rs +++ b/src/model/course.rs @@ -4,8 +4,8 @@ //! The course file: identity plus the registries every bank references. //! -//! Learning objectives and lectures are declared once, in `course.yaml`, and -//! referenced by id from items. That is the single most load-bearing decision in +//! Learning objectives, their learning targets, and lectures are declared once, +//! in `course.yaml`, and referenced by id from items. That is the single most load-bearing decision in //! the schema. It means an objective's wording lives in exactly one place, so //! rewording it updates every report; it means a report can name what a student //! missed by objective rather than by question number; and it means a dangling @@ -61,9 +61,27 @@ pub struct CourseFile { pub lectures: BTreeMap, /// Learning objectives, keyed by id such as `lo-mm-kinetics`. + /// + /// The tier a syllabus lists and a report classifies. Objectives only: the + /// performances they are met by live in [`CourseFile::learning_targets`]. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub learning_objectives: BTreeMap, + /// Learning targets, keyed by id such as `t-mm-kcat-from-plot`. + /// + /// The tier items are tagged to. Each names the objective it belongs to, so + /// the two tiers are separate sections rather than one section with a field + /// distinguishing them: reading `course.yaml` you can see the twelve claims + /// the course makes without scrolling past the two hundred performances they + /// are built from, and a target cannot accidentally be written as an + /// objective by leaving a field out. + /// + /// Target ids are deliberately spelled differently from objective ids — no + /// `lo` prefix — so that any id appearing in an item, a reading, or a report + /// says which tier it belongs to without a lookup. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub learning_targets: BTreeMap, + /// Works the course cites, keyed by citation key such as /// `kuriyan2013molecules`. Readings point in here rather than restating a /// citation, so a reference is written once and a changed edition is one edit. @@ -396,8 +414,8 @@ pub struct Reading { pub url: Option, /// Whether it is assigned or offered alongside. pub role: ReadingRole, - /// The objectives this reading serves. - pub objectives: Vec, + /// The learning targets this reading serves. + pub targets: Vec, /// What the section contains. pub summary: Option, /// What to take from it, which is the sentence a study suggestion quotes. @@ -474,7 +492,7 @@ impl Reading { impl Serialize for Reading { fn serialize(&self, s: S) -> std::result::Result { if let Some(text) = &self.text { - if self.reference.is_none() && self.locator.is_none() && self.objectives.is_empty() { + if self.reference.is_none() && self.locator.is_none() && self.targets.is_empty() { return s.serialize_str(text); } } @@ -494,8 +512,8 @@ impl Serialize for Reading { if self.role != ReadingRole::Assigned { map.serialize_entry("role", &self.role)?; } - if !self.objectives.is_empty() { - map.serialize_entry("objectives", &self.objectives)?; + if !self.targets.is_empty() { + map.serialize_entry("targets", &self.targets)?; } if let Some(v) = &self.summary { map.serialize_entry("summary", v)?; @@ -534,8 +552,8 @@ impl<'de> Deserialize<'de> for Reading { url: Option, #[serde(default)] role: ReadingRole, - #[serde(default)] - objectives: Vec, + #[serde(default, alias = "objectives")] + targets: Vec, #[serde(default)] summary: Option, #[serde(default)] @@ -569,7 +587,7 @@ impl<'de> Deserialize<'de> for Reading { path: m.path, url: m.url, role: m.role, - objectives: m.objectives, + targets: m.targets, summary: m.summary, focus: m.focus, skip: m.skip, @@ -581,7 +599,35 @@ impl<'de> Deserialize<'de> for Reading { } } -/// A learning objective. +/// A learning objective: the tier a claim is made about. +/// +/// # On objectives and targets +/// +/// The vocabulary follows the assessment literature, where the two words name +/// two different jobs rather than two sizes of the same thing. +/// +/// A **learning objective** is what a syllabus lists, what a blueprint requires +/// items against, and what a report classifies as met or not met. It is 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 what one question can +/// actually measure. Targets are the evidence a claim about an objective rests +/// on. +/// +/// The distinction earns its keep because the two tiers cannot be the same +/// thing. An objective specific enough to write a good item against is too +/// specific to report on, since three hundred of them means one question each +/// and one question supports no claim. An objective broad enough to report on +/// says nothing about what the item should ask. +/// +/// The two live in separate registries, and [`Target`] is a separate type that +/// names its objective in a required field. That makes the two-tier depth a +/// property of the schema rather than a rule the validator has to enforce: +/// there is nowhere for a third level to be written. A deeper tree would make +/// "met this objective" ambiguous, because the answer would depend on which +/// level you rolled up to, and it would put one item in three or four different +/// denominators. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Objective { @@ -596,16 +642,22 @@ pub struct Objective { pub lectures: Vec, /// Position in teaching order, low first. /// - /// The registry is a map, so declaration order is lost on load, and sorting by - /// id would put `lo-enthalpy` before `lo-first-law` when the second is a - /// prerequisite of the first. Anything that prints objectives in the order you - /// teach them, a lecture page above all, needs this. Objectives without it sort - /// last, by id. + /// The registry is a map, so declaration order is lost on load, and sorting + /// by id would put `lo-enthalpy` before `lo-first-law` when the second is a + /// prerequisite of the first. Anything that prints objectives in the order + /// you teach them, a lecture page above all, needs this. Objectives without + /// it sort last, by id. #[serde(default, skip_serializing_if = "Option::is_none")] pub order: Option, /// The highest level you intend to assess this objective at. Assembling an /// item above the ceiling is a warning: either the item overreaches or the /// ceiling needs raising. + /// + /// A target without one of its own inherits this; see + /// [`CourseFile::effective_level_ceiling`]. So an objective's ceiling is the + /// ceiling of everything under it, which is the useful reading: you decide + /// once that recognition is assessed no higher than Analyze, and every + /// target you add afterwards is checked against that. #[serde(default, skip_serializing_if = "Option::is_none")] pub level_ceiling: Option, /// Objectives that must be secure before this one is reachable. Student @@ -616,10 +668,65 @@ pub struct Objective { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, /// Whether this objective is assessed at all, or is aspirational. + /// + /// Marking an objective unassessed exempts its targets too; see + /// [`CourseFile::is_assessed`]. It is the one-line way to park a whole topic + /// you taught but decided not to test, without editing twenty targets. #[serde(default = "yes")] pub assessed: bool, } +/// A learning target: one performance an item can be written against. +/// +/// Targets are what items are tagged to, what readings are cited against, and +/// what a report names when explaining why an objective came out the way it did. +/// Results roll up to [`Target::objective`], so a target's own rate is evidence +/// rather than a claim: it usually rests on one or two questions. +/// +/// It is a distinct type from [`Objective`] rather than the same type with a +/// nullable link, so that the objective it belongs to is required by the schema +/// and a third tier has nowhere to be written. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Target { + /// The target as you would state it to students. Reports quote this + /// verbatim, so write it in the second person and start with a verb. + pub text: String, + /// The objective this target belongs to. + /// + /// Required: a target with no objective would be measured and never + /// reported, since every statistic a report makes is computed per objective + /// from the items tagged to its targets. + pub objective: String, + /// The lectures that develop it. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub lectures: Vec, + /// Position among the other targets of the same objective, low first. + /// + /// Ordered within its objective rather than across the course, so two + /// targets under different objectives never compete for a position and + /// inserting one renumbers nothing outside its own group. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub order: Option, + /// The highest level you intend to assess this target at. Omit it to inherit + /// the objective's. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub level_ceiling: Option, + /// Targets or objectives that must be secure before this one is reachable. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub prerequisites: Vec, + /// Free-form tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// Whether this target is assessed at all, or is aspirational. An + /// unassessed objective exempts its targets regardless of this. + #[serde(default = "yes")] + pub assessed: bool, +} + +/// The prefix an objective id is expected to carry. +pub const OBJECTIVE_PREFIX: &str = "lo"; + /// A shared stem or vignette used by several items. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -796,21 +903,21 @@ impl CourseFile { } } - for (id, lo) in &self.learning_objectives { - if lo.text.trim().is_empty() { + for (id, objective) in &self.learning_objectives { + if objective.text.trim().is_empty() { issues.push(format!("objective `{id}`: empty text")); } - if let Some(u) = &lo.unit { + if let Some(u) = &objective.unit { if !unit_ids.contains(&u) { issues.push(format!("objective `{id}`: unknown unit `{u}`")); } } - for lec in &lo.lectures { + for lec in &objective.lectures { if !self.lectures.contains_key(lec) { issues.push(format!("objective `{id}`: unknown lecture `{lec}`")); } } - for pre in &lo.prerequisites { + for pre in &objective.prerequisites { if !self.learning_objectives.contains_key(pre) { issues.push(format!( "objective `{id}`: unknown prerequisite objective `{pre}`" @@ -822,10 +929,110 @@ impl CourseFile { } } + for (id, target) in &self.learning_targets { + issues.extend(self.target_issues(id, target)); + } + + // An id in both registries would make every lookup order-dependent, and + // `objective_for` would answer differently depending on which map it + // consulted first. + for id in self.learning_targets.keys() { + if self.learning_objectives.contains_key(id) { + issues.push(format!( + "`{id}` is declared as both a learning objective and a learning target" + )); + } + } + issues.extend(self.prerequisite_cycles()); issues } + /// Checks one learning target. + /// + /// The schema already rules out a third tier and a target with no objective, + /// so what is left are the cross-references and the two places where a + /// target and its objective could contradict each other: a ceiling above the + /// objective's would leave the pair disagreeing about how hard the topic is + /// assessed, and an assessed target under an unassessed objective would be + /// measured and never reported. + /// + /// # Arguments + /// + /// * `id` - the target's id. + /// * `target` - the target. + /// + /// # Returns + /// + /// One message per problem. + fn target_issues(&self, id: &str, target: &Target) -> Vec { + let mut issues = Vec::new(); + + if target.text.trim().is_empty() { + issues.push(format!("target `{id}`: empty text")); + } + // Target ids are spelled differently from objective ids so that an id in + // an item, a reading, or a report says which tier it belongs to without + // a lookup. Enforced, because the moment one target is named `lo-...` + // the convention stops being usable for reading a file. + if id + .trim_start_matches(|c: char| !c.is_ascii_alphanumeric()) + .starts_with(OBJECTIVE_PREFIX) + { + issues.push(format!( + "target `{id}`: a target id must not start with `{OBJECTIVE_PREFIX}`, which is \ + how an objective id is spelled. Try `t-{}`.", + id.trim_start_matches(|c: char| !c.is_ascii_alphanumeric()) + .trim_start_matches(OBJECTIVE_PREFIX) + .trim_start_matches('-') + )); + } + + for lec in &target.lectures { + if !self.lectures.contains_key(lec) { + issues.push(format!("target `{id}`: unknown lecture `{lec}`")); + } + } + for pre in &target.prerequisites { + if !self.learning_targets.contains_key(pre) + && !self.learning_objectives.contains_key(pre) + { + issues.push(format!("target `{id}`: unknown prerequisite `{pre}`")); + } + if pre == id { + issues.push(format!("target `{id}`: lists itself as a prerequisite")); + } + } + + let Some(objective) = self.learning_objectives.get(&target.objective) else { + issues.push(format!( + "target `{id}`: unknown objective `{}`", + target.objective + )); + return issues; + }; + if let (Some(target_ceiling), Some(objective_ceiling)) = + (target.level_ceiling, objective.level_ceiling) + { + if target_ceiling > objective_ceiling { + issues.push(format!( + "target `{id}`: level_ceiling {target_ceiling} is above objective `{}`'s \ + ceiling of {objective_ceiling}. Raise the objective's ceiling if you mean \ + to assess the topic that high.", + target.objective + )); + } + } + if target.assessed && !objective.assessed { + issues.push(format!( + "target `{id}`: marked assessed, but objective `{}` is marked \ + `assessed: false`, so nothing under it is reported", + target.objective + )); + } + issues + } + /// Checks one reading, collecting every problem with it. /// /// # Arguments @@ -878,9 +1085,21 @@ impl CourseFile { seen.push((key, locator)); } - for objective in &reading.objectives { - if !self.learning_objectives.contains_key(objective) { - issues.push(format!("{at}: unknown learning objective `{objective}`")); + for target in &reading.targets { + if self.learning_targets.contains_key(target) { + continue; + } + match self.learning_objectives.get(target) { + None => issues.push(format!("{at}: unknown learning target `{target}`")), + // An objective with no targets stands as its own, so citing it + // is fine; an objective with targets is never the place to cite + // a reading, since a reading backs one performance. + Some(_) if !self.targets(target).is_empty() => issues.push(format!( + "{at}: `{target}` is an objective with {} target(s); cite the specific \ + target this section serves", + self.targets(target).len() + )), + Some(_) => {} } } issues @@ -968,6 +1187,30 @@ impl CourseFile { }) } + /// Looks up a target, erroring on a dangling reference. + /// + /// # Arguments + /// + /// * `id` - the target id. + /// * `context` - what referenced it, for the error message. + /// + /// # Returns + /// + /// The target. + /// + /// # Errors + /// + /// Returns [`Error::Unresolved`] when the id is not registered. + pub fn target(&self, id: &str, context: &str) -> Result<&Target> { + self.learning_targets + .get(id) + .ok_or_else(|| Error::Unresolved { + kind: "learning target", + id: id.to_string(), + context: Some(context.to_string()), + }) + } + /// Looks up a lecture, erroring on a dangling reference. /// /// # Arguments @@ -990,31 +1233,266 @@ impl CourseFile { }) } - /// The objective text, or the bare id when unregistered. + /// The objective an id rolls up to. /// - /// Report rendering uses this so a missing objective degrades to a readable - /// label instead of failing a whole report. + /// The single function the rest of the tool goes through to turn an item's + /// tag into a reporting unit, so a target and an objective can be handled by + /// one code path. /// /// # Arguments /// - /// * `id` - the objective id. + /// * `id` - a target id or an objective id. + /// + /// # Returns + /// + /// The objective a target belongs to; otherwise `id` itself, which covers + /// both an objective and an id that is not registered at all. An + /// unregistered id is its own objective because validation reports it + /// elsewhere, and a report that silently dropped it would be worse than one + /// showing a row labeled with the bare id. + /// + /// The result borrows from the course in one branch and from `id` in the + /// other, so the two share a single lifetime rather than taking the elided + /// one from `&self`. + pub fn objective_for<'a>(&'a self, id: &'a str) -> &'a str { + match self.learning_targets.get(id) { + Some(target) if self.learning_objectives.contains_key(&target.objective) => { + target.objective.as_str() + } + _ => self + .learning_objectives + .get_key_value(id) + .map(|(k, _)| k.as_str()) + .or_else(|| { + self.learning_targets + .get_key_value(id) + .map(|(k, _)| k.as_str()) + }) + .unwrap_or(id), + } + } + + /// Whether an id names a registered learning objective. + /// + /// # Arguments + /// + /// * `id` - the id to check. + /// + /// # Returns + /// + /// `true` when it is in the objective registry. + pub fn is_objective(&self, id: &str) -> bool { + self.learning_objectives.contains_key(id) + } + + /// Whether an id names a registered learning target. + /// + /// # Arguments + /// + /// * `id` - the id to check. + /// + /// # Returns + /// + /// `true` when it is in the target registry. + pub fn is_target(&self, id: &str) -> bool { + self.learning_targets.contains_key(id) + } + + /// The targets of an objective, in teaching order. + /// + /// # Arguments + /// + /// * `objective` - the objective's id. + /// + /// # Returns + /// + /// Target ids ordered by [`Target::order`] then by id, empty for an + /// objective that has none. + pub fn targets(&self, objective: &str) -> Vec<&str> { + let mut ids: Vec<&String> = self + .learning_targets + .iter() + .filter(|(_, target)| target.objective == objective) + .map(|(id, _)| id) + .collect(); + ids.sort_by_key(|id| { + let target = &self.learning_targets[*id]; + (target.order.unwrap_or(u32::MAX), (*id).clone()) + }); + ids.into_iter().map(String::as_str).collect() + } + + /// The registry's own copy of an id, from whichever tier holds it. + /// + /// Anything that walks [`CourseFile::registry_in_order`] and needs to return + /// borrowed ids goes through this, since that walk yields owned `String`s + /// spanning both registries. + /// + /// # Arguments + /// + /// * `id` - a target id or an objective id. + /// + /// # Returns + /// + /// The key as stored, or `None` when neither registry holds it. + pub fn registry_key(&self, id: &str) -> Option<&str> { + // The two maps hold different value types, so the lookups cannot be + // chained before the key is extracted from each. + if let Some((key, _)) = self.learning_objectives.get_key_value(id) { + return Some(key.as_str()); + } + self.learning_targets + .get_key_value(id) + .map(|(key, _)| key.as_str()) + } + + /// The text of an objective or a target, or the bare id when unregistered. + /// + /// Report rendering uses this so a missing id degrades to a readable label + /// instead of failing a whole report. + /// + /// # Arguments + /// + /// * `id` - a target id or an objective id. /// /// # Returns /// /// The display text. - pub fn objective_text(&self, id: &str) -> String { - self.learning_objectives + pub fn text_for(&self, id: &str) -> String { + if let Some(objective) = self.learning_objectives.get(id) { + return objective.text.clone(); + } + self.learning_targets .get(id) - .map(|o| o.text.clone()) + .map(|t| t.text.clone()) .unwrap_or_else(|| id.to_string()) } - /// Objectives in a stable teaching order: by unit as declared, then by - /// [`Objective::order`], then by id. + /// The lectures that develop an objective or a target. + /// + /// # Arguments + /// + /// * `id` - a target id or an objective id. /// /// # Returns /// - /// Objective ids in report order. + /// The declared lecture ids, empty when the id is unregistered. + pub fn lectures_for(&self, id: &str) -> &[String] { + if let Some(objective) = self.learning_objectives.get(id) { + return &objective.lectures; + } + match self.learning_targets.get(id) { + Some(target) => &target.lectures, + None => &[], + } + } + + /// The unit an objective or target belongs to. + /// + /// # Arguments + /// + /// * `id` - a target id or an objective id. + /// + /// # Returns + /// + /// The unit id. A target has no unit of its own and takes its objective's, + /// which is the only coherent answer: a target in a different unit from its + /// objective would appear twice in any report ordered by unit. + pub fn objective_unit(&self, id: &str) -> Option<&str> { + if let Some(objective) = self.learning_objectives.get(id) { + return objective.unit.as_deref(); + } + let target = self.learning_targets.get(id)?; + self.learning_objectives + .get(&target.objective)? + .unit + .as_deref() + } + + /// The level an objective or target may be assessed up to. + /// + /// # Arguments + /// + /// * `id` - a target id or an objective id. + /// + /// # Returns + /// + /// A target's own ceiling, else its objective's, else `None` for no ceiling. + pub fn effective_level_ceiling(&self, id: &str) -> Option { + if let Some(objective) = self.learning_objectives.get(id) { + return objective.level_ceiling; + } + let target = self.learning_targets.get(id)?; + if let Some(ceiling) = target.level_ceiling { + return Some(ceiling); + } + self.learning_objectives + .get(&target.objective)? + .level_ceiling + } + + /// Whether an objective or target is assessed. + /// + /// # Arguments + /// + /// * `id` - a target id or an objective id. + /// + /// # Returns + /// + /// `false` when the id itself or the objective above it is marked + /// `assessed: false`, and `false` for an unregistered id. + pub fn is_assessed(&self, id: &str) -> bool { + if let Some(objective) = self.learning_objectives.get(id) { + return objective.assessed; + } + let Some(target) = self.learning_targets.get(id) else { + return false; + }; + target.assessed + && self + .learning_objectives + .get(&target.objective) + .is_some_and(|o| o.assessed) + } + + /// Both registries in a stable teaching order: each objective immediately + /// followed by its own targets. + /// + /// Interleaving the two by `order` would be meaningless, because a target's + /// `order` is a position among its siblings rather than among the course. + /// Grouping targets under their objective is also what a report wants: the + /// claim, then the evidence for it. + /// + /// # Returns + /// + /// Objective and target ids in report order. + pub fn registry_in_order(&self) -> Vec { + let mut out = + Vec::with_capacity(self.learning_objectives.len() + self.learning_targets.len()); + for id in self.objectives_in_order() { + let targets = self.targets(&id); + out.push(id); + out.extend(targets.into_iter().map(str::to_string)); + } + // A target whose objective is missing would otherwise vanish from every + // report. Validation reports the dangling id; this keeps the row. + for (id, target) in &self.learning_targets { + if !self.learning_objectives.contains_key(&target.objective) { + out.push(id.clone()); + } + } + out + } + + /// Objectives in teaching order. + /// + /// This is the list a syllabus prints, a blueprint requires items against, + /// and a report classifies. It is short by construction, which is the point. + /// + /// # Returns + /// + /// Objective ids ordered by unit as declared, then by [`Objective::order`], + /// then by id. pub fn objectives_in_order(&self) -> Vec { let unit_rank: BTreeMap<&str, usize> = self .units @@ -1024,18 +1502,37 @@ impl CourseFile { .collect(); let mut ids: Vec<&String> = self.learning_objectives.keys().collect(); ids.sort_by_key(|id| { - let lo = &self.learning_objectives[*id]; - let rank = lo + let objective = &self.learning_objectives[*id]; + let rank = objective .unit .as_deref() .and_then(|u| unit_rank.get(u).copied()) .unwrap_or(usize::MAX); - (rank, lo.order.unwrap_or(u32::MAX), (*id).clone()) + (rank, objective.order.unwrap_or(u32::MAX), (*id).clone()) }); ids.into_iter().cloned().collect() } - /// The objectives a lecture covers, in teaching order. + /// The ids items may be tagged with, in teaching order. + /// + /// Every target, plus any objective that has no targets. An objective that + /// has them is not taggable: the item would land in its denominator without + /// recording which performance the question asked for, and that record is + /// what a report drills into. An objective with none stands as its own + /// target, which is what lets a course adopt the second tier one unit at a + /// time. + /// + /// # Returns + /// + /// Taggable ids in report order. + pub fn targets_in_order(&self) -> Vec { + self.registry_in_order() + .into_iter() + .filter(|id| self.targets(id).is_empty()) + .collect() + } + + /// Every objective and target a lecture covers, in teaching order. /// /// # Arguments /// @@ -1043,20 +1540,74 @@ impl CourseFile { /// /// # Returns /// - /// Objective ids whose `lectures` list names this lecture, ordered by - /// [`Objective::order`] and then by id. + /// Ids whose `lectures` list names this lecture, in [`registry_in_order`] + /// order. + /// + /// [`registry_in_order`]: CourseFile::registry_in_order + pub fn lecture_entries(&self, lecture: &str) -> Vec<&str> { + let mut out: Vec<&str> = Vec::new(); + for id in self.registry_in_order() { + if self.lectures_for(&id).iter().any(|l| l == lecture) { + // Borrow the key rather than the owned String from the ordering. + if let Some((key, _)) = self.learning_objectives.get_key_value(&id) { + out.push(key.as_str()); + } else if let Some((key, _)) = self.learning_targets.get_key_value(&id) { + out.push(key.as_str()); + } + } + } + out + } + + /// The objectives a lecture covers, in teaching order. + /// + /// This is what a lecture's objectives page lists: four to eight claims, not + /// the forty performances they are built from. + /// + /// # Arguments + /// + /// * `lecture` - the lecture id. + /// + /// # Returns + /// + /// Objective ids the lecture names directly, plus the objectives of any + /// target it names, deduplicated and in teaching order. A lecture that lists + /// only targets still has objectives, and leaving them off its page would + /// make the page depend on whether you happened to tag the objective with + /// the lecture as well. pub fn lecture_objectives(&self, lecture: &str) -> Vec<&str> { - let mut ids: Vec<&String> = self - .learning_objectives - .iter() - .filter(|(_, lo)| lo.lectures.iter().any(|l| l == lecture)) - .map(|(id, _)| id) - .collect(); - ids.sort_by_key(|id| { - let lo = &self.learning_objectives[*id]; - (lo.order.unwrap_or(u32::MAX), (*id).clone()) - }); - ids.into_iter().map(String::as_str).collect() + let mut out: Vec<&str> = Vec::new(); + for id in self.objectives_in_order() { + let Some((key, objective)) = self.learning_objectives.get_key_value(&id) else { + continue; + }; + let taught_directly = objective.lectures.iter().any(|l| l == lecture); + let taught_through_a_target = self + .targets(key) + .into_iter() + .any(|t| self.lectures_for(t).iter().any(|l| l == lecture)); + if taught_directly || taught_through_a_target { + out.push(key.as_str()); + } + } + out + } + + /// The targets a lecture covers, in teaching order. + /// + /// # Arguments + /// + /// * `lecture` - the lecture id. + /// + /// # Returns + /// + /// Ids of the lecture's targets, plus any objective it names that has no + /// targets and so stands as its own. + pub fn lecture_targets(&self, lecture: &str) -> Vec<&str> { + self.lecture_entries(lecture) + .into_iter() + .filter(|id| self.targets(id).is_empty()) + .collect() } /// Every reading that serves an objective, with the lecture it was assigned in. @@ -1078,7 +1629,15 @@ impl CourseFile { let mut out = Vec::new(); for (lecture_id, lecture) in &self.lectures { for reading in &lecture.readings { - if reading.objectives.iter().any(|o| o == objective) { + // A reading cited against a target is a reading for that + // target's objective, which is what lets a student report answer + // "where do I go and read about this" from an objective a report + // classified rather than only from the target a question missed. + if reading + .targets + .iter() + .any(|t| t == objective || self.objective_for(t) == objective) + { out.push((lecture_id.as_str(), reading)); } } @@ -1086,27 +1645,42 @@ impl CourseFile { out } - /// Assessed objectives with no reading behind them. + /// Assessed targets with no reading behind them. /// - /// These are the objectives a student report cannot advise on: it can say the - /// objective was missed, but not where to go and read about it. + /// These are the things a student report cannot advise on: it can say the + /// target was missed, but not where to go and read about it. + /// + /// Reported at the tier readings are cited against, which is the target, so + /// a course is not told off once per target and again per objective. /// /// # Returns /// - /// Objective ids in teaching order. - pub fn objectives_without_readings(&self) -> Vec<&str> { + /// Target ids in teaching order. + pub fn targets_without_readings(&self) -> Vec<&str> { let cited: std::collections::BTreeSet<&str> = self .lectures .values() .flat_map(|l| l.readings.iter()) - .flat_map(|r| r.objectives.iter()) + .flat_map(|r| r.targets.iter()) .map(String::as_str) .collect(); - self.objectives_in_order() + self.registry_in_order() .into_iter() .filter_map(|id| { - let (key, lo) = self.learning_objectives.get_key_value(&id)?; - (lo.assessed && !cited.contains(key.as_str())).then_some(key.as_str()) + // The id may name either registry, so the key has to be + // resolved from both. Looking in only one of them silently + // dropped every row this function exists to report. + let key = self.registry_key(&id)?; + if !self.is_assessed(key) { + return None; + } + let targets = self.targets(key); + let covered = + cited.contains(key) || targets.iter().any(|target| cited.contains(target)); + // An objective whose targets carry the readings is covered, and + // an objective with targets is never itself the place to cite + // one. + (!covered && targets.is_empty()).then_some(key) }) .collect() } @@ -1133,14 +1707,16 @@ impl CourseFile { }) } - /// Expands `{objective-id}` in a prose field to whatever the caller wants. + /// Expands `{objective-or-target-id}` in a prose field to whatever the + /// caller wants. /// - /// Reading notes refer to objectives in passing ("a worked instance of - /// `{lo-vdw-additivity}`"), and a lecture page renders that as a number while a - /// student report renders it as text. Only a name that resolves to a declared - /// objective is treated as a placeholder, so `$U_\text{final}$` passes through - /// untouched; that collision is the reason this is not a general template - /// syntax. + /// Reading notes refer to targets in passing ("a worked instance of + /// `{t-vdw-additivity}`"), and a lecture page renders that as a number while + /// a student report renders it as text. Both registries are consulted, since + /// a note in a migrated course names targets almost exclusively. Only a name + /// that resolves to a declared id is treated as a placeholder, so + /// `$U_\text{final}$` passes through untouched; that collision is the reason + /// this is not a general template syntax. /// /// # Arguments /// @@ -1161,7 +1737,9 @@ impl CourseFile { return out; }; let name = &tail[1..close]; - if self.learning_objectives.contains_key(name) { + if self.learning_objectives.contains_key(name) + || self.learning_targets.contains_key(name) + { out.push_str(&render(name)); } else { out.push_str(&tail[..=close]); @@ -1199,7 +1777,7 @@ impl CourseFile { los.insert( "lo-example".to_string(), Objective { - text: "Replace this with an objective stated as a student action.".to_string(), + text: "Replace this with an objective: the claim a report should make.".to_string(), unit: Some("u-intro".to_string()), lectures: vec!["L01".to_string()], order: Some(1), @@ -1209,6 +1787,23 @@ impl CourseFile { assessed: true, }, ); + // One target as well, because the two tiers are easier to understand + // from an example than from the schema. + let mut targets = BTreeMap::new(); + targets.insert( + "t-example".to_string(), + Target { + text: "Replace this with a target: one performance an item can measure." + .to_string(), + objective: "lo-example".to_string(), + lectures: vec!["L01".to_string()], + order: Some(1), + level_ceiling: None, + prerequisites: Vec::new(), + tags: Vec::new(), + assessed: true, + }, + ); CourseFile { schema_version: SCHEMA_VERSION.to_string(), course: Course { @@ -1227,6 +1822,7 @@ impl CourseFile { }], lectures, learning_objectives: los, + learning_targets: targets, references: BTreeMap::new(), stimuli: BTreeMap::new(), } @@ -1376,7 +1972,7 @@ learning_objectives: "#, ); // Declared order wins over alphabetical order of unit ids. - assert_eq!(c.objectives_in_order(), vec!["lo-z", "lo-a"]); + assert_eq!(c.registry_in_order(), vec!["lo-z", "lo-a"]); } #[test] @@ -1404,14 +2000,14 @@ lectures: - ref: kuriyan2013molecules locator: '§6.1' path: '6/A/#1' - objectives: [lo-a] + targets: [lo-a] summary: What a system is. focus: Fix the definitions. - ref: kuriyan2013molecules locator: '§1.9' path: '1/B/#9' role: supplemental - objectives: [lo-b] + targets: [lo-b] learning_objectives: lo-a: { text: A, lectures: [L1.1], order: 1 } lo-b: { text: B, lectures: [L1.1], order: 2 } @@ -1472,7 +2068,7 @@ lectures: } #[test] - fn readings_resolve_backwards_from_an_objective() { + fn readings_resolve_backwards_from_a_target() { let c = with_readings(); let found = c.readings_for_objective("lo-a"); assert_eq!(found.len(), 1); @@ -1482,9 +2078,9 @@ lectures: } #[test] - fn an_objective_with_no_reading_is_reported() { + fn an_entry_with_no_reading_is_reported() { let mut c = with_readings(); - assert!(c.objectives_without_readings().is_empty()); + assert!(c.targets_without_readings().is_empty()); c.learning_objectives.insert( "lo-orphan".to_string(), Objective { @@ -1498,14 +2094,14 @@ lectures: assessed: true, }, ); - assert_eq!(c.objectives_without_readings(), vec!["lo-orphan"]); + assert_eq!(c.targets_without_readings(), vec!["lo-orphan"]); - // An objective you teach but do not test is not a gap. + // Something you teach but do not test is not a gap. c.learning_objectives .get_mut("lo-orphan") .expect("just inserted") .assessed = false; - assert!(c.objectives_without_readings().is_empty()); + assert!(c.targets_without_readings().is_empty()); } #[test] @@ -1541,7 +2137,7 @@ lectures: title: One readings: - { ref: missing, locator: '§1' } - - { ref: known, locator: '§2', path: '2/', objectives: [lo-nope] } + - { ref: known, locator: '§2', path: '2/', targets: [lo-nope] } "#, ); let issues = c.validate(); @@ -1553,7 +2149,7 @@ lectures: assert!( issues .iter() - .any(|i| i.contains("unknown learning objective `lo-nope`")) + .any(|i| i.contains("unknown learning target `lo-nope`")) ); // `path` with no base_url to join it to. assert!(issues.iter().any(|i| i.contains("base_url"))); @@ -1584,6 +2180,322 @@ lectures: ); } + /// Two objectives, one with targets and one standing on its own. + fn two_tier() -> CourseFile { + parse( + r#" +course: { code: X, title: Y, term: Z } +units: + - { id: u-1, title: One } +learning_objectives: + lo-binding: + text: Quantify single-site binding. + unit: u-1 + order: 1 + level_ceiling: 4 + lo-standalone: + text: An objective with no targets, reported on directly. + unit: u-1 + order: 2 +learning_targets: + t-kd-expression: + text: Write the expression for the dissociation constant. + objective: lo-binding + order: 2 + t-read-kd-from-plot: + text: Determine the dissociation constant from a plotted isotherm. + objective: lo-binding + order: 1 + level_ceiling: 3 +"#, + ) + } + + #[test] + fn a_two_tier_registry_validates() { + let c = two_tier(); + assert!(c.validate().is_empty(), "{:?}", c.validate()); + } + + #[test] + fn targets_roll_up_and_objectives_stand_alone() { + let c = two_tier(); + assert_eq!(c.objective_for("t-kd-expression"), "lo-binding"); + assert_eq!(c.objective_for("lo-binding"), "lo-binding"); + assert_eq!(c.objective_for("lo-standalone"), "lo-standalone"); + // An unregistered id is its own reporting unit rather than a dropped row. + assert_eq!(c.objective_for("t-typo"), "t-typo"); + + assert!(c.is_objective("lo-binding")); + assert!(!c.is_objective("t-kd-expression")); + assert!(c.is_target("t-kd-expression")); + assert!(!c.is_target("lo-binding")); + assert!(!c.is_target("t-typo"), "an unregistered id is not a target"); + } + + #[test] + fn the_registries_are_separate_sections() { + let c = two_tier(); + assert_eq!(c.learning_objectives.len(), 2, "objectives only"); + assert_eq!(c.learning_targets.len(), 2, "targets only"); + assert_eq!(c.objectives_in_order(), vec!["lo-binding", "lo-standalone"]); + assert_eq!( + c.targets("lo-binding"), + vec!["t-read-kd-from-plot", "t-kd-expression"] + ); + assert!(c.targets("lo-standalone").is_empty()); + // Items are tagged with targets, plus any objective that has none. + assert_eq!( + c.targets_in_order(), + vec!["t-read-kd-from-plot", "t-kd-expression", "lo-standalone"] + ); + } + + #[test] + fn targets_follow_their_own_objective_in_report_order() { + let c = two_tier(); + // `t-kd-expression` has order 2 and `lo-standalone` has order 2, but a + // target sorts among its siblings rather than against the whole course. + assert_eq!( + c.registry_in_order(), + vec![ + "lo-binding", + "t-read-kd-from-plot", + "t-kd-expression", + "lo-standalone" + ] + ); + } + + #[test] + fn a_target_inherits_unit_ceiling_and_assessment() { + let mut c = two_tier(); + // A target has no unit of its own; it takes its objective's. + assert_eq!(c.objective_unit("t-kd-expression"), Some("u-1")); + // Declared on the target, so not inherited. + assert_eq!( + c.effective_level_ceiling("t-read-kd-from-plot"), + Some(Level::Apply) + ); + // Absent on the target, so the objective's applies. + assert_eq!( + c.effective_level_ceiling("t-kd-expression"), + Some(Level::Analyze) + ); + + assert!(c.is_assessed("t-kd-expression")); + c.learning_objectives + .get_mut("lo-binding") + .expect("declared") + .assessed = false; + assert!( + !c.is_assessed("t-kd-expression"), + "parking an objective parks every target under it" + ); + } + + #[test] + fn a_target_id_may_not_be_spelled_like_an_objective() { + // The prefix convention is what lets an id in an item or a report say + // which tier it belongs to without a lookup, so it is enforced. + let c = parse( + r#" +course: { code: X, title: Y, term: Z } +learning_objectives: + lo-a: { text: A } +learning_targets: + lo-b: { text: B, objective: lo-a } +"#, + ); + let issues = c.validate(); + assert!( + issues + .iter() + .any(|i| i.contains("must not start with `lo`")), + "expected a naming complaint, got {issues:?}" + ); + assert!( + issues.iter().any(|i| i.contains("`t-b`")), + "and a suggested id, got {issues:?}" + ); + } + + #[test] + fn an_id_in_both_registries_is_reported() { + let c = parse( + r#" +course: { code: X, title: Y, term: Z } +learning_objectives: + lo-a: { text: A } + t-b: { text: 'Also an objective, confusingly' } +learning_targets: + t-b: { text: B, objective: lo-a } +"#, + ); + let issues = c.validate(); + assert!( + issues + .iter() + .any(|i| i.contains("both a learning objective and a learning target")), + "got {issues:?}" + ); + } + + #[test] + fn a_target_naming_an_unknown_objective_is_reported() { + let c = parse( + r#" +course: { code: X, title: Y, term: Z } +learning_targets: + t-a: { text: A, objective: lo-missing } +"#, + ); + let issues = c.validate(); + assert!( + issues + .iter() + .any(|i| i.contains("unknown objective `lo-missing`")), + "got {issues:?}" + ); + // It still appears in report order rather than vanishing. + assert_eq!(c.registry_in_order(), vec!["t-a"]); + } + + #[test] + fn a_target_may_not_outreach_its_objectives_ceiling() { + let c = parse( + r#" +course: { code: X, title: Y, term: Z } +learning_objectives: + lo-a: { text: A, level_ceiling: 2 } +learning_targets: + t-b: { text: B, objective: lo-a, level_ceiling: 4 } +"#, + ); + let issues = c.validate(); + assert!( + issues.iter().any(|i| i.contains("is above objective")), + "expected a ceiling complaint, got {issues:?}" + ); + } + + #[test] + fn a_reading_is_cited_against_a_target_and_serves_its_objective() { + let c = parse( + r#" +course: { code: X, title: Y, term: Z } +references: + kkw: { title: The molecules of life, label: KKW } +lectures: + L1.1: + title: Binding + readings: + - { ref: kkw, locator: '§6.1', targets: [t-kd] } +learning_objectives: + lo-binding: { text: Quantify binding., order: 1, lectures: [L1.1] } +learning_targets: + t-kd: { text: Write the expression., objective: lo-binding, order: 1, lectures: [L1.1] } + t-isotherm: { text: Read an isotherm., objective: lo-binding, order: 2, lectures: [L1.1] } +"#, + ); + assert!(c.validate().is_empty(), "{:?}", c.validate()); + // The objective is never itself the place to cite a reading, and the + // target that has one is not a gap. Its sibling is. + assert_eq!(c.targets_without_readings(), vec!["t-isotherm"]); + // A reading cited against a target answers "what do I read for this + // objective" too. + assert_eq!(c.readings_for_objective("lo-binding").len(), 1); + assert_eq!(c.readings_for_objective("t-kd").len(), 1); + } + + #[test] + fn a_reading_may_not_cite_an_objective_that_has_targets() { + let c = parse( + r#" +course: { code: X, title: Y, term: Z } +references: + kkw: { title: The molecules of life } +lectures: + L1.1: + title: Binding + readings: + - { ref: kkw, locator: '§6.1', targets: [lo-binding] } +learning_objectives: + lo-binding: { text: Quantify binding. } +learning_targets: + t-kd: { text: Write the expression., objective: lo-binding } +"#, + ); + let issues = c.validate(); + assert!( + issues.iter().any(|i| i.contains("cite the specific")), + "expected a tier complaint, got {issues:?}" + ); + } + + #[test] + fn a_lecture_page_sees_both_tiers() { + let c = parse( + r#" +course: { code: X, title: Y, term: Z } +lectures: + L1.1: { title: Binding } +learning_objectives: + lo-binding: { text: Quantify binding., order: 1, lectures: [L1.1] } + lo-solo: { text: A target-less objective., order: 2, lectures: [L1.1] } +learning_targets: + t-kd: { text: Write the expression., objective: lo-binding, order: 1, lectures: [L1.1] } + t-plot: { text: Read a plot., objective: lo-binding, order: 2, lectures: [L1.1] } +"#, + ); + assert_eq!(c.lecture_objectives("L1.1"), vec!["lo-binding", "lo-solo"]); + assert_eq!( + c.lecture_targets("L1.1"), + vec!["t-kd", "t-plot", "lo-solo"], + "a target-less objective stands as its own target" + ); + } + + #[test] + fn a_lecture_that_lists_only_targets_still_has_objectives() { + // The objective is not tagged with the lecture; only its targets are. + let c = parse( + r#" +course: { code: X, title: Y, term: Z } +lectures: + L1.1: { title: Binding } +learning_objectives: + lo-binding: { text: Quantify binding., order: 1 } +learning_targets: + t-kd: { text: Write the expression., objective: lo-binding, order: 1, lectures: [L1.1] } +"#, + ); + assert_eq!(c.lecture_objectives("L1.1"), vec!["lo-binding"]); + } + + #[test] + fn a_target_id_is_a_placeholder_too() { + // Reading notes in a migrated course name targets, so resolving only + // objectives left every placeholder unexpanded. + let c = parse( + r#" +course: { code: X, title: Y, term: Z } +learning_objectives: + lo-binding: { text: Quantify binding. } +learning_targets: + t-kd: { text: Write the expression., objective: lo-binding } +"#, + ); + let expanded = c.expand_objective_refs( + r"a worked instance of {t-kd}, under {lo-binding}, where $U_\text{final}$ is fixed", + |id| format!("<{id}>"), + ); + assert_eq!( + expanded, + r"a worked instance of , under , where $U_\text{final}$ is fixed" + ); + } + #[test] fn only_a_declared_objective_id_is_a_placeholder() { let c = with_readings(); diff --git a/src/model/item.rs b/src/model/item.rs index fb8bc82..ae7e980 100644 --- a/src/model/item.rs +++ b/src/model/item.rs @@ -102,9 +102,19 @@ pub struct Item { #[serde(default, skip_serializing_if = "Option::is_none")] pub solution: Option, - /// Objectives this item measures, as ids into the course registry. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub learning_objectives: Vec, + /// 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, /// 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, - /// 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, @@ -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(), diff --git a/src/model/seal.rs b/src/model/seal.rs index dbf6170..f482e7a 100644 --- a/src/model/seal.rs +++ b/src/model/seal.rs @@ -161,7 +161,7 @@ pub struct SealedItem { pub level: Option, /// Objectives as administered. #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub learning_objectives: Vec, + pub learning_targets: Vec, /// The keyed letters in the bank's own lettering, before any shuffle. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub key: Vec, @@ -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![ diff --git a/src/model/taxonomy.rs b/src/model/taxonomy.rs index b0946c4..51f52cd 100644 --- a/src/model/taxonomy.rs +++ b/src/model/taxonomy.rs @@ -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