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
+126 -33
View File
@@ -79,7 +79,7 @@ pub struct BankMeta {
/// What a bank is scoped to.
///
/// A bank may be scoped by lecture, by objective, by topic, or by none of them.
/// A bank may be scoped by lecture, by learning target, by topic, or by none of them.
/// Declaring the scope is what lets the catalog report *gaps*: it can only tell
/// you that lecture 12 has no Apply-level items if it knows lecture 12 is
/// supposed to be covered here.
@@ -89,9 +89,13 @@ pub struct Scope {
/// Lectures this bank draws from.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lectures: Vec<String>,
/// Objectives this bank is responsible for covering.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec<String>,
/// Learning targets this bank is responsible for covering.
#[serde(
default,
alias = "learning_objectives",
skip_serializing_if = "Vec::is_empty"
)]
pub learning_targets: Vec<String>,
/// Units this bank belongs to.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub units: Vec<String>,
@@ -254,9 +258,9 @@ impl BankFile {
issues.push(format!("bank.scope: unknown lecture `{lec}`"));
}
}
for lo in &self.bank.scope.learning_objectives {
if !c.learning_objectives.contains_key(lo) {
issues.push(format!("bank.scope: unknown learning objective `{lo}`"));
for target in &self.bank.scope.learning_targets {
if !c.is_target(target) && !c.is_objective(target) {
issues.push(format!("bank.scope: unknown learning target `{target}`"));
}
}
}
@@ -563,8 +567,8 @@ fn validate_item(
if it.cognitive_process.is_none() {
issues.push("approved items must declare a cognitive_process".into());
}
if it.learning_objectives.is_empty() {
issues.push("approved items must reference at least one learning objective".into());
if it.learning_targets.is_empty() {
issues.push("approved items must reference at least one learning target".into());
}
if it.sources.is_empty() {
issues.push("approved items must cite at least one source".into());
@@ -590,26 +594,64 @@ fn validate_item(
// --- cross-file references ----
if let Some(c) = course {
for lo in &it.learning_objectives {
match c.learning_objectives.get(lo) {
None => issues.push(format!("unknown learning objective `{lo}`")),
Some(obj) => {
if let Some(ceiling) = obj.level_ceiling {
if it.level > ceiling {
issues.push(format!(
"level {} exceeds the ceiling {} declared for objective `{lo}`",
it.level.code(),
ceiling.code()
));
}
}
if !obj.assessed {
issues.push(format!(
"objective `{lo}` is marked `assessed: false` but this item measures it"
));
}
for tag in &it.learning_targets {
let is_target = c.is_target(tag);
if !is_target && !c.is_objective(tag) {
issues.push(format!("unknown learning target `{tag}`"));
continue;
}
// Items are tagged at the target tier. Tagging an objective that has
// targets would put the item in that objective's denominator without
// recording which performance the question actually asked for, and
// that record is what a report drills into and what coverage
// analysis counts. An objective with no targets stands as its own.
let its_targets = c.targets(tag);
if !its_targets.is_empty() {
issues.push(format!(
"`{tag}` is an objective with {} learning target(s); tag the specific \
target this item measures instead",
its_targets.len()
));
}
// The ceiling may be inherited from the objective, so a target that
// declares none of its own is still bounded.
if let Some(ceiling) = c.effective_level_ceiling(tag) {
if it.level > ceiling {
let declares_its_own = if is_target {
c.learning_targets
.get(tag)
.is_some_and(|t| t.level_ceiling.is_some())
} else {
true
};
let source = if declares_its_own {
format!("declared for `{tag}`")
} else {
format!(
"inherited by `{tag}` from its objective `{}`",
c.objective_for(tag)
)
};
issues.push(format!(
"level {} exceeds the ceiling {} {source}",
it.level.code(),
ceiling.code()
));
}
}
if !c.is_assessed(tag) {
let parked_above =
is_target && c.learning_targets.get(tag).is_some_and(|t| t.assessed);
let which = if parked_above {
format!(
"its objective `{}` is marked `assessed: false`",
c.objective_for(tag)
)
} else {
format!("`{tag}` is marked `assessed: false`")
};
issues.push(format!("{which} but this item measures it"));
}
}
for s in &it.sources {
if !c.lectures.contains_key(&s.lecture) {
@@ -707,7 +749,7 @@ mod tests {
level: 2
format: open_response
cognitive_process: explain
learning_objectives: [lo-x]
learning_targets: [lo-x]
sources: [{ lecture: L1.1 }]
design: { rationale: r }
stem: Explain the first law.
@@ -827,7 +869,7 @@ mod tests {
let issues = b.validate(None);
for want in [
"cognitive_process",
"learning objective",
"learning target",
"source",
"design block",
] {
@@ -914,7 +956,7 @@ learning_objectives:
status: draft
level: 4
stem: s
learning_objectives: [lo-known, lo-unknown]
learning_targets: [lo-known, lo-unknown]
sources: [{ lecture: L99 }]
options:
- { id: A, text: a, correct: true }
@@ -925,7 +967,7 @@ learning_objectives:
assert!(
issues
.iter()
.any(|i| i.contains("unknown learning objective `lo-unknown`"))
.any(|i| i.contains("unknown learning target `lo-unknown`"))
);
assert!(issues.iter().any(|i| i.contains("unknown lecture `L99`")));
assert!(
@@ -934,6 +976,57 @@ learning_objectives:
);
}
#[test]
fn an_item_must_tag_a_target_not_an_objective_that_has_them() {
let course: CourseFile = serde_yaml_ng::from_str(
r#"
course: { code: X, title: Y, term: Z }
learning_objectives:
lo-binding: { text: Quantify binding., level_ceiling: 3 }
lo-solo: { text: An objective with no targets. }
learning_targets:
t-kd: { text: Write the expression., objective: lo-binding }
"#,
)
.unwrap();
let b = bank(
r#"
- id: q-a-001
status: draft
level: 1
stem: s
learning_targets: [lo-binding]
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
- id: q-a-002
status: draft
level: 1
stem: s
learning_targets: [t-kd, lo-solo]
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
"#,
);
let issues = b.validate(Some(&course));
assert!(
issues
.iter()
.any(|i| i.contains("`lo-binding` is an objective with 1 learning target(s)")),
"got {issues:?}"
);
// A target is fine, and so is an objective that has no targets: it
// stands as its own, which is what lets a course migrate a unit at a
// time.
assert!(
!issues
.iter()
.any(|i| i.contains("`t-kd`") || i.contains("`lo-solo`")),
"got {issues:?}"
);
}
#[test]
fn history_versions_must_increase() {
let b = bank(
@@ -964,7 +1057,7 @@ learning_objectives:
level: 1
cognitive_process: recall
stem: s
learning_objectives: [lo]
learning_targets: [lo]
sources: [{ lecture: L1 }]
design: { expected_difficulty: 0.8 }
options:
@@ -983,7 +1076,7 @@ learning_objectives:
cognitive_process: generate
bonus: true
stem: s
learning_objectives: [lo]
learning_targets: [lo]
sources: [{ lecture: L1 }]
design: { expected_difficulty: 0.3 }
options: