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

This commit is contained in:
2026-09-21 14:02:34 -04:00
parent 9755417899
commit 8ec48fb185
32 changed files with 2396 additions and 448 deletions
+98 -15
View File
@@ -29,7 +29,7 @@ use std::collections::{BTreeMap, BTreeSet};
use crate::assessment::{Assessment, AssessmentFile, Blueprint, Form, Kind, Placement, Platform};
use crate::catalog::Catalog;
use crate::course::SCHEMA_VERSION;
use crate::course::{CourseFile, SCHEMA_VERSION};
use crate::date::Date;
use crate::error::{Error, Result};
use crate::history::History;
@@ -106,10 +106,20 @@ pub fn select(
// to become unsatisfiable once the level quotas are full.
for (objective, needed) in &blueprint.objective_minimums {
let mut have = 0;
// A requirement written against an objective is satisfied by items on
// any of its targets, which is the only way a coverage requirement stays
// writable: a blueprint that had to name each target separately would be
// as long as the registry, and would need editing every time an
// objective gained one.
let mut candidates: Vec<&crate::catalog::Entry> = eligible
.iter()
.copied()
.filter(|e| e.item.learning_objectives.iter().any(|o| o == objective))
.filter(|e| {
e.item
.learning_targets
.iter()
.any(|t| t == objective || catalog.course.objective_for(t) == objective)
})
.filter(|e| !e.item.bonus)
.collect();
rank(&mut candidates, history, seed, "objective");
@@ -465,7 +475,7 @@ pub fn to_record(
bonus: is_bonus || e.item.bonus,
key: e.item.key_letters(),
level: Some(e.item.level),
learning_objectives: e.item.learning_objectives.clone(),
learning_targets: e.item.learning_targets.clone(),
credit_overrides: BTreeMap::new(),
dropped: false,
dropped_as: None,
@@ -585,11 +595,14 @@ pub fn option_order(form: &Form, uid: &str, n: usize) -> Vec<usize> {
/// # Arguments
///
/// * `record` - the assessment record.
/// * `course` - the course, for the objective each tagged target belongs to. An
/// objective minimum is satisfied by items on any of that objective's
/// targets, the same way [`select`] fills it.
///
/// # Returns
///
/// One message per discrepancy, empty when the form matches the design.
pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
pub fn check_blueprint(record: &AssessmentFile, course: &CourseFile) -> Vec<String> {
let Some(bp) = &record.blueprint else {
return vec!["the record carries no blueprint to check against".into()];
};
@@ -609,7 +622,11 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
let got = record
.items
.iter()
.filter(|p| p.learning_objectives.iter().any(|o| o == objective))
.filter(|p| {
p.learning_targets
.iter()
.any(|t| t == objective || course.objective_for(t) == objective)
})
.count();
if got < *needed {
out.push(format!(
@@ -620,7 +637,7 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
out
}
/// The set of objectives an assessment covers.
/// The set of learning targets an assessment covers, as tagged.
///
/// # Arguments
///
@@ -628,12 +645,36 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
///
/// # Returns
///
/// The objective ids, deduplicated.
pub fn covered_objectives(record: &AssessmentFile) -> BTreeSet<String> {
/// The target ids, deduplicated.
pub fn covered_targets(record: &AssessmentFile) -> BTreeSet<String> {
record
.items
.iter()
.flat_map(|p| p.learning_objectives.iter().cloned())
.flat_map(|p| p.learning_targets.iter().cloned())
.collect()
}
/// The objectives an assessment covers, through the targets it measured.
///
/// The list a coverage claim should be made from: "this exam covered eleven of
/// the course's thirty-two objectives" is a sentence about the blueprint, while
/// the same count over targets is a sentence about how finely the course happens
/// to be subdivided.
///
/// # Arguments
///
/// * `record` - the assessment record.
/// * `course` - the course, for the objective each tagged target belongs to.
///
/// # Returns
///
/// The objective ids, deduplicated.
pub fn covered_objectives(record: &AssessmentFile, course: &CourseFile) -> BTreeSet<String> {
record
.items
.iter()
.flat_map(|p| p.learning_targets.iter())
.map(|t| course.objective_for(t).to_string())
.collect()
}
@@ -690,12 +731,14 @@ blueprint:
level_counts: { 1: 2, 3: 1 }
objective_minimums: { lo-key: 2 }
items:
- { number: 1, item: "b::q-1", level: 1, learning_objectives: [lo-key] }
- { number: 1, item: "b::q-1", level: 1, learning_targets: [lo-key] }
- { number: 2, item: "b::q-2", level: 1 }
"#,
)
.unwrap();
let issues = check_blueprint(&record);
let course: CourseFile =
serde_yaml_ng::from_str("course: { code: C, title: T, term: M }").unwrap();
let issues = check_blueprint(&record, &course);
assert!(issues.iter().any(|i| i.contains("level 3")), "{issues:?}");
assert!(issues.iter().any(|i| i.contains("lo-key")), "{issues:?}");
// Level 1 matches, so it must not be reported.
@@ -703,17 +746,57 @@ items:
}
#[test]
fn covered_objectives_deduplicates() {
fn an_objective_minimum_is_met_by_items_on_its_targets() {
// The blueprint names the objective a report will classify; the items
// are tagged with the specific performances they measure.
let record: AssessmentFile = serde_yaml_ng::from_str(
r#"
assessment: { id: x, title: X }
blueprint:
objective_minimums: { lo-binding: 2 }
items:
- { number: 1, item: "b::q-1", level: 1, learning_targets: [t-kd] }
- { number: 2, item: "b::q-2", level: 3, learning_targets: [t-plot] }
"#,
)
.unwrap();
let course: CourseFile = serde_yaml_ng::from_str(
r#"
course: { code: C, title: T, term: M }
learning_objectives:
lo-binding: { text: Quantify binding. }
learning_targets:
t-kd: { text: Write the expression., objective: lo-binding }
t-plot: { text: Read a plot., objective: lo-binding }
"#,
)
.unwrap();
let issues = check_blueprint(&record, &course);
assert!(
!issues.iter().any(|i| i.contains("lo-binding")),
"two items on its targets satisfy it: {issues:?}"
);
assert_eq!(
covered_objectives(&record, &course)
.into_iter()
.collect::<Vec<_>>(),
vec!["lo-binding"],
"coverage is claimed at the tier the blueprint is written in"
);
}
#[test]
fn covered_targets_deduplicates() {
let record: AssessmentFile = serde_yaml_ng::from_str(
r#"
assessment: { id: x, title: X }
items:
- { number: 1, item: "b::q-1", learning_objectives: [lo-a, lo-b] }
- { number: 2, item: "b::q-2", learning_objectives: [lo-a] }
- { number: 1, item: "b::q-1", learning_targets: [lo-a, lo-b] }
- { number: 2, item: "b::q-2", learning_targets: [lo-a] }
"#,
)
.unwrap();
let set = covered_objectives(&record);
let set = covered_targets(&record);
assert_eq!(set.len(), 2);
assert!(set.contains("lo-a"));
}