feat: improvement
Pipeline / check (pull_request) Successful in 5m0s
Pipeline / docs (pull_request) Skipped
Pipeline / nightly (pull_request) Skipped
Pipeline / release (pull_request) Skipped

This commit is contained in:
2026-09-27 01:11:09 -04:00
parent 5ac1e317c0
commit c6d6ee10b6
18 changed files with 2538 additions and 108 deletions
+127 -10
View File
@@ -37,6 +37,10 @@ pub enum Kind {
Lecture,
/// `objectives/*.yaml`.
Objective,
/// `analysis/calibration.yaml`.
Calibration,
/// `analysis/administrations/*.yaml`.
Measurements,
/// `banks/*.yaml`.
Bank,
/// `assessments/*.yaml`.
@@ -45,13 +49,15 @@ pub enum Kind {
impl Kind {
/// Every kind.
pub const ALL: [Kind; 6] = [
pub const ALL: [Kind; 8] = [
Kind::Course,
Kind::References,
Kind::Lecture,
Kind::Objective,
Kind::Bank,
Kind::Assessment,
Kind::Calibration,
Kind::Measurements,
];
/// The file name a schema is written to.
@@ -61,6 +67,8 @@ impl Kind {
Kind::References => "references.schema.json",
Kind::Lecture => "lecture.schema.json",
Kind::Objective => "objective.schema.json",
Kind::Calibration => "calibration.schema.json",
Kind::Measurements => "measurements.schema.json",
Kind::Bank => "bank.schema.json",
Kind::Assessment => "assessment.schema.json",
}
@@ -99,6 +107,8 @@ pub fn schema(kind: Kind) -> Value {
Kind::References => references_schema(),
Kind::Lecture => lecture_fragment_schema(),
Kind::Objective => objective_fragment_schema(),
Kind::Calibration => calibration_file_schema(),
Kind::Measurements => measurements_file_schema(),
Kind::Bank => bank_schema(),
Kind::Assessment => assessment_schema(),
}
@@ -381,9 +391,12 @@ fn target_schema() -> Value {
"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."
"description": "Ignored since 2.0. An objective's targets are a set of question \
templates, not steps in a sequence — they are not taught in \
order and an exam samples from them — so a position asserts an \
order that does not exist. Where one target depends on another, \
say so with `prerequisites`. `coursebank migrate order` removes \
this."
},
"level_ceiling": level(),
"prerequisites": string_array(
@@ -412,8 +425,10 @@ fn objective_schema() -> Value {
"order": {
"type": "integer",
"minimum": 1,
"description": "Position in teaching order, low first. Without it objectives \
sort by id, which puts one before its own prerequisite."
"description": "Position in teaching order, low first. Derived since 2.0 from \
the position of this objective in a lecture's `teaches` list; \
an authored value still wins, and `coursebank migrate order` \
removes them."
},
"level_ceiling": level(),
"prerequisites": string_array(
@@ -1058,6 +1073,106 @@ fn option_history_schema() -> Value {
})
}
/// The schema for `analysis/calibration.yaml`.
fn calibration_file_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": format!("{BASE}/calibration.schema.json"),
"title": "coursebank calibration store",
"description": "The pooled statistics, by item id. Kept out of the banks: a bank's diff \
should be a change of intent, not the output of a grading run. Committed \
— every number here is a cohort aggregate, and there is no field for a \
student.",
"type": "object",
"additionalProperties": false,
"properties": {
"schema_version": { "type": ["string", "number"] },
"items": {
"type": "object",
"description": "Keyed by item id, which since 2.0 names the item course-wide and \
carries no file name, so moving a question between banks does \
not orphan its statistics.",
"additionalProperties": calibration_schema()
}
}
})
}
/// The schema for one file under `analysis/administrations/`.
fn measurements_file_schema() -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": format!("{BASE}/measurements.schema.json"),
"title": "coursebank administration record",
"description": "What one administration measured. Written once and not rewritten, like \
a seal: it records a thing that happened on a day. Cohort aggregates \
only — no per-section or per-student breakdown, because a small cell \
crossed with anything else stops being an aggregate.",
"type": "object",
"required": ["administration"],
"additionalProperties": false,
"properties": {
"schema_version": { "type": ["string", "number"] },
"administration": {
"type": "object",
"required": ["id", "assessment", "n_examinees"],
"additionalProperties": false,
"properties": {
"id": { "type": "string" },
"assessment": { "type": "string" },
"term": { "type": "string" },
"date": date("When it was given."),
"forms": string_array("The forms in play."),
"n_examinees": {
"type": "integer",
"minimum": 0,
"description": "The number to read before any of the others: a \
point-biserial on twenty-seven students is a different \
kind of claim than one on three hundred."
},
"model": { "type": "string" },
"generated": date("When the analysis was run."),
"coursebank": { "type": "string" }
}
},
"items": {
"type": "array",
"items": {
"type": "object",
"required": ["item", "number", "n"],
"additionalProperties": false,
"properties": {
"item": { "type": "string" },
"number": { "type": "integer", "minimum": 1 },
"variant": { "type": "string" },
"stem_digest": { "type": "string" },
"n": { "type": "integer", "minimum": 0 },
"p_value": proportion("Proportion correct on this administration."),
"point_biserial": { "type": "number", "minimum": -1.0, "maximum": 1.0 },
"discrimination_index": {
"type": "number", "minimum": -1.0, "maximum": 1.0
},
"option_stats": {
"type": "object",
"additionalProperties": option_stat_schema()
},
"irt": irt_schema(),
"flags": {
"type": "array",
"items": {
"type": "string",
"enum": strings(
&Flag::ALL.iter().map(|f| f.as_str()).collect::<Vec<&str>>()
)
}
}
}
}
}
}
})
}
/// The schema for a retirement record.
fn retirement_schema() -> Value {
json!({
@@ -1080,9 +1195,12 @@ fn item_identity_properties() -> Value {
json!({
"id": {
"type": "string",
"pattern": "^q-[a-z0-9]+(-[a-z0-9]+)*-[0-9]{3}$",
"description": "Item id, e.g. q-glycolysis-014. Stable forever: assessment records \
and stored responses refer to it."
"pattern": "^q-[a-z0-9]+(-[a-z0-9]+)*$",
"description": "Item id, e.g. q-glycolysis-rate-limiting-step. Stable forever: \
assessment records and stored responses refer to it, so renaming one \
is a migration rather than an edit. A trailing counter is no longer \
expected — it recorded when the item was written, which git knows — \
but an id that still has one stays valid."
},
"version": {
"type": "integer",
@@ -1149,7 +1267,6 @@ fn item_content_properties() -> Value {
"prerequisites": string_array("Objective ids a student needs before this item."),
"assets": { "type": "array", "items": asset_schema() },
"design": design_schema(),
"calibration": calibration_schema(),
"review": review_schema(),
"history": {
"type": "array",