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
+68 -15
View File
@@ -338,6 +338,41 @@ fn lecture_schema() -> Value {
})
}
/// The schema for one learning target.
fn target_schema() -> Value {
json!({
"type": "object",
"required": ["text", "objective"],
"additionalProperties": false,
"properties": {
"text": text("The target as a student would read it. Start with a verb."),
"objective": {
"type": "string",
"description": "The objective this target belongs to. Required: a target with \
no objective would be measured and never reported."
},
"lectures": string_array("Lecture ids that cover this."),
"order": {
"type": "integer",
"minimum": 1,
"description": "Position among the other targets of the same objective, low \
first. Ordered within its objective rather than across the \
course, so inserting one renumbers nothing outside its group."
},
"level_ceiling": level(),
"prerequisites": string_array(
"Target or objective ids that must come first. Cycles are rejected."
),
"tags": string_array("Free-form tags."),
"assessed": {
"type": "boolean",
"description": "Set false for something you teach but do not test. An \
unassessed objective exempts its targets regardless."
}
}
})
}
/// The schema for one learning objective.
fn objective_schema() -> Value {
json!({
@@ -351,9 +386,8 @@ fn objective_schema() -> Value {
"order": {
"type": "integer",
"minimum": 1,
"description": "Position in teaching order, low first. A lecture page numbers \
objectives by this; without it they sort by id, which puts \
an objective before its own prerequisite."
"description": "Position in teaching order, low first. Without it objectives \
sort by id, which puts one before its own prerequisite."
},
"level_ceiling": level(),
"prerequisites": string_array(
@@ -363,7 +397,8 @@ fn objective_schema() -> Value {
"assessed": {
"type": "boolean",
"description": "Set false for an objective you teach but do not test; coverage \
reporting will stop flagging it as a gap."
reporting will stop flagging it as a gap. This exempts its \
targets too."
}
}
})
@@ -444,9 +479,12 @@ fn reading_mapping_schema() -> Value {
"enum": strings(&["assigned", "supplemental"]),
"description": "supplemental means offered but not separately assessed."
},
"objectives": string_array(
"Objective ids this reading serves. A student who misses one of these is \
pointed here, so the list is what makes study guidance specific."
"targets": string_array(
"Target ids this reading serves. A student who misses one of these is pointed \
here, so the list is what makes study guidance specific. Cite targets rather \
than objectives: a section of a book backs a specific performance, and a \
reading list resolved from an objective would send a student the same six \
sections whichever part of it they missed."
),
"summary": text("What the section contains."),
"focus": text("What to take from it. This is the sentence a student report quotes."),
@@ -501,10 +539,20 @@ fn course_schema() -> Value {
},
"learning_objectives": {
"type": "object",
"description": "Objectives by id. Everything downstream — coverage, mastery, \
student reports — keys off these.",
"description": "Learning objectives by id, conventionally `lo-...`. The tier a \
syllabus lists and a report classifies as met. Objectives only: \
the performances they are met by go in `learning_targets`.",
"additionalProperties": objective_schema()
},
"learning_targets": {
"type": "object",
"description": "Learning targets by id. Each names the objective it belongs to. \
This is the tier items are tagged to and readings are cited \
against; results roll up to the objective. Ids must not start \
with `lo`, so that an id in an item or a report says which tier \
it belongs to without a lookup — `t-...` is the convention.",
"additionalProperties": target_schema()
},
"stimuli": {
"type": "object",
"description": "Shared passages, figures, or data that several items refer to.",
@@ -880,9 +928,11 @@ fn item_content_properties() -> Value {
"items": option_schema()
},
"solution": solution_schema(),
"learning_objectives": string_array(
"Objective ids this item measures. Reports aggregate on these, so an item with none \
contributes to nothing."
"learning_targets": string_array(
"Target ids this item measures. Reports aggregate these onto the target's objective, \
so an item with none contributes to nothing. Name the target rather than the \
objective: the objective follows from it, and recording which target was asked \
about is what lets a report explain a result instead of only stating it."
),
"sources": {
"type": "array",
@@ -948,7 +998,7 @@ fn bank_scope_schema() -> Value {
outside it.",
"properties": {
"lectures": string_array("Lecture ids."),
"learning_objectives": string_array("Objective ids."),
"learning_targets": string_array("Target ids."),
"units": string_array("Unit ids."),
"topics": string_array("Topics.")
}
@@ -1044,7 +1094,10 @@ fn blueprint_schema() -> Value {
"type": "object",
"description": "Minimum items per objective. Placed before level quotas, because \
a coverage requirement is the constraint most likely to become \
unsatisfiable.",
unsatisfiable. A requirement is satisfied by items on any of \
that objective's targets, so this stays short: name the dozen \
objectives the exam is meant to cover, not the hundred targets \
it is built from.",
"additionalProperties": { "type": "integer", "minimum": 0 }
},
"lectures": string_array("Restrict the draw to these lectures."),
@@ -1106,7 +1159,7 @@ fn placement_schema() -> Value {
"bonus": { "type": "boolean" },
"key": string_array("Keyed option letters as administered."),
"level": level(),
"learning_objectives": string_array("Objectives as administered."),
"learning_targets": string_array("Targets as administered."),
"credit_overrides": {
"type": "object",
"description": "Partial credit decided after the fact, by option letter. Recording \
+98 -15
View File
@@ -29,7 +29,7 @@ use std::collections::{BTreeMap, BTreeSet};
use crate::assessment::{Assessment, AssessmentFile, Blueprint, Form, Kind, Placement, Platform};
use crate::catalog::Catalog;
use crate::course::SCHEMA_VERSION;
use crate::course::{CourseFile, SCHEMA_VERSION};
use crate::date::Date;
use crate::error::{Error, Result};
use crate::history::History;
@@ -106,10 +106,20 @@ pub fn select(
// to become unsatisfiable once the level quotas are full.
for (objective, needed) in &blueprint.objective_minimums {
let mut have = 0;
// A requirement written against an objective is satisfied by items on
// any of its targets, which is the only way a coverage requirement stays
// writable: a blueprint that had to name each target separately would be
// as long as the registry, and would need editing every time an
// objective gained one.
let mut candidates: Vec<&crate::catalog::Entry> = eligible
.iter()
.copied()
.filter(|e| e.item.learning_objectives.iter().any(|o| o == objective))
.filter(|e| {
e.item
.learning_targets
.iter()
.any(|t| t == objective || catalog.course.objective_for(t) == objective)
})
.filter(|e| !e.item.bonus)
.collect();
rank(&mut candidates, history, seed, "objective");
@@ -465,7 +475,7 @@ pub fn to_record(
bonus: is_bonus || e.item.bonus,
key: e.item.key_letters(),
level: Some(e.item.level),
learning_objectives: e.item.learning_objectives.clone(),
learning_targets: e.item.learning_targets.clone(),
credit_overrides: BTreeMap::new(),
dropped: false,
dropped_as: None,
@@ -585,11 +595,14 @@ pub fn option_order(form: &Form, uid: &str, n: usize) -> Vec<usize> {
/// # Arguments
///
/// * `record` - the assessment record.
/// * `course` - the course, for the objective each tagged target belongs to. An
/// objective minimum is satisfied by items on any of that objective's
/// targets, the same way [`select`] fills it.
///
/// # Returns
///
/// One message per discrepancy, empty when the form matches the design.
pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
pub fn check_blueprint(record: &AssessmentFile, course: &CourseFile) -> Vec<String> {
let Some(bp) = &record.blueprint else {
return vec!["the record carries no blueprint to check against".into()];
};
@@ -609,7 +622,11 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
let got = record
.items
.iter()
.filter(|p| p.learning_objectives.iter().any(|o| o == objective))
.filter(|p| {
p.learning_targets
.iter()
.any(|t| t == objective || course.objective_for(t) == objective)
})
.count();
if got < *needed {
out.push(format!(
@@ -620,7 +637,7 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
out
}
/// The set of objectives an assessment covers.
/// The set of learning targets an assessment covers, as tagged.
///
/// # Arguments
///
@@ -628,12 +645,36 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
///
/// # Returns
///
/// The objective ids, deduplicated.
pub fn covered_objectives(record: &AssessmentFile) -> BTreeSet<String> {
/// The target ids, deduplicated.
pub fn covered_targets(record: &AssessmentFile) -> BTreeSet<String> {
record
.items
.iter()
.flat_map(|p| p.learning_objectives.iter().cloned())
.flat_map(|p| p.learning_targets.iter().cloned())
.collect()
}
/// The objectives an assessment covers, through the targets it measured.
///
/// The list a coverage claim should be made from: "this exam covered eleven of
/// the course's thirty-two objectives" is a sentence about the blueprint, while
/// the same count over targets is a sentence about how finely the course happens
/// to be subdivided.
///
/// # Arguments
///
/// * `record` - the assessment record.
/// * `course` - the course, for the objective each tagged target belongs to.
///
/// # Returns
///
/// The objective ids, deduplicated.
pub fn covered_objectives(record: &AssessmentFile, course: &CourseFile) -> BTreeSet<String> {
record
.items
.iter()
.flat_map(|p| p.learning_targets.iter())
.map(|t| course.objective_for(t).to_string())
.collect()
}
@@ -690,12 +731,14 @@ blueprint:
level_counts: { 1: 2, 3: 1 }
objective_minimums: { lo-key: 2 }
items:
- { number: 1, item: "b::q-1", level: 1, learning_objectives: [lo-key] }
- { number: 1, item: "b::q-1", level: 1, learning_targets: [lo-key] }
- { number: 2, item: "b::q-2", level: 1 }
"#,
)
.unwrap();
let issues = check_blueprint(&record);
let course: CourseFile =
serde_yaml_ng::from_str("course: { code: C, title: T, term: M }").unwrap();
let issues = check_blueprint(&record, &course);
assert!(issues.iter().any(|i| i.contains("level 3")), "{issues:?}");
assert!(issues.iter().any(|i| i.contains("lo-key")), "{issues:?}");
// Level 1 matches, so it must not be reported.
@@ -703,17 +746,57 @@ items:
}
#[test]
fn covered_objectives_deduplicates() {
fn an_objective_minimum_is_met_by_items_on_its_targets() {
// The blueprint names the objective a report will classify; the items
// are tagged with the specific performances they measure.
let record: AssessmentFile = serde_yaml_ng::from_str(
r#"
assessment: { id: x, title: X }
blueprint:
objective_minimums: { lo-binding: 2 }
items:
- { number: 1, item: "b::q-1", level: 1, learning_targets: [t-kd] }
- { number: 2, item: "b::q-2", level: 3, learning_targets: [t-plot] }
"#,
)
.unwrap();
let course: CourseFile = serde_yaml_ng::from_str(
r#"
course: { code: C, title: T, term: M }
learning_objectives:
lo-binding: { text: Quantify binding. }
learning_targets:
t-kd: { text: Write the expression., objective: lo-binding }
t-plot: { text: Read a plot., objective: lo-binding }
"#,
)
.unwrap();
let issues = check_blueprint(&record, &course);
assert!(
!issues.iter().any(|i| i.contains("lo-binding")),
"two items on its targets satisfy it: {issues:?}"
);
assert_eq!(
covered_objectives(&record, &course)
.into_iter()
.collect::<Vec<_>>(),
vec!["lo-binding"],
"coverage is claimed at the tier the blueprint is written in"
);
}
#[test]
fn covered_targets_deduplicates() {
let record: AssessmentFile = serde_yaml_ng::from_str(
r#"
assessment: { id: x, title: X }
items:
- { number: 1, item: "b::q-1", learning_objectives: [lo-a, lo-b] }
- { number: 2, item: "b::q-2", learning_objectives: [lo-a] }
- { number: 1, item: "b::q-1", learning_targets: [lo-a, lo-b] }
- { number: 2, item: "b::q-2", learning_targets: [lo-a] }
"#,
)
.unwrap();
let set = covered_objectives(&record);
let set = covered_targets(&record);
assert_eq!(set.len(), 2);
assert!(set.contains("lo-a"));
}