feat: add learning objective targets
This commit is contained in:
+126
-33
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user