// SPDX-License-Identifier: Prosperity-3.0.0 // Copyright Scientific Computing Studio // Source: https://git.scient.ing/education/coursebank //! Emitting JSON Schema for the YAML formats. //! //! The point of this module is autocomplete. Every editor with a YAML language //! server reads a `# yaml-language-server: $schema=...` modeline, and once it does, //! writing an item becomes a matter of tabbing through valid `cognitive_process` //! values instead of looking them up. That is a much better authoring experience //! than running a validator afterward and reading a list of typos. //! //! The schemas are written by hand rather than derived from the Rust types. That is //! a real cost — two definitions to keep in step — bought for two reasons: the //! schema can carry prose descriptions aimed at whoever is writing the item, which //! is what shows up in editor tooltips, and it can encode `enum` value lists that a //! generic derivation would emit as bare strings. The crate's own validation //! remains authoritative; the schema is for the editor. use serde_json::{Value, json}; use crate::course::SCHEMA_VERSION; use crate::error::Result; use crate::taxonomy::{CognitiveProcess, ErrorType, Flag, Level}; /// The base URL schemas refer to each other by. const BASE: &str = "https://coursebank.dev/schema"; /// The three schema kinds. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Kind { /// `course.yaml`. Course, /// `banks/*.yaml`. Bank, /// `assessments/*.yaml`. Assessment, } impl Kind { /// All three kinds. pub const ALL: [Kind; 3] = [Kind::Course, Kind::Bank, Kind::Assessment]; /// The file name a schema is written to. pub fn filename(self) -> &'static str { match self { Kind::Course => "course.schema.json", Kind::Bank => "bank.schema.json", Kind::Assessment => "assessment.schema.json", } } /// The modeline that points an editor at this schema. /// /// # Arguments /// /// * `relative_path` - the path from the YAML file to the schema directory. /// /// # Returns /// /// A comment line to place at the top of the YAML file. pub fn modeline(self, relative_path: &str) -> String { format!( "# yaml-language-server: $schema={}/{}", relative_path.trim_end_matches('/'), self.filename() ) } } /// Builds a schema. /// /// # Arguments /// /// * `kind` - which schema to build. /// /// # Returns /// /// The schema as JSON. pub fn schema(kind: Kind) -> Value { match kind { Kind::Course => course_schema(), Kind::Bank => bank_schema(), Kind::Assessment => assessment_schema(), } } /// Writes all three schemas to a directory. /// /// # Arguments /// /// * `dir` - the destination directory. /// /// # Returns /// /// The paths written. /// /// # Errors /// /// Returns [`crate::error::Error::Io`] on a write failure. pub fn write_all(dir: &std::path::Path) -> Result> { std::fs::create_dir_all(dir).map_err(|e| crate::error::Error::io(dir, e))?; let mut written = Vec::new(); for kind in Kind::ALL { let path = dir.join(kind.filename()); crate::yaml::write_json(&path, &schema(kind))?; written.push(path); } Ok(written) } /// The string values of an enum, for a schema `enum` list. fn strings>(values: &[T]) -> Value { Value::Array( values .iter() .map(|v| Value::String(v.as_ref().to_string())) .collect(), ) } /// A schema fragment for a required non-empty string. fn text(description: &str) -> Value { json!({ "type": "string", "minLength": 1, "description": description }) } /// A schema fragment for an array of strings. fn string_array(description: &str) -> Value { json!({ "type": "array", "items": { "type": "string" }, "description": description }) } /// A schema fragment for a proportion in `[0, 1]`. fn proportion(description: &str) -> Value { json!({ "type": "number", "minimum": 0.0, "maximum": 1.0, "description": description }) } /// A schema fragment for a `YYYY-MM-DD` date. fn date(description: &str) -> Value { json!({ "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$", "description": description }) } /// The level enum, with the taxonomy spelled out in the description so it appears /// in editor tooltips. fn level() -> Value { let descriptions: Vec = Level::ALL .iter() .map(|l| format!("{} = {} ({})", l.code(), l.name(), l.blurb())) .collect(); json!({ "type": "integer", "minimum": 1, "maximum": 5, "description": format!("Cognitive level. {}", descriptions.join("; ")) }) } /// The cognitive process enum, grouped by level in the description. fn cognitive_process() -> Value { let all: Vec<&str> = CognitiveProcess::ALL.iter().map(|p| p.as_str()).collect(); let by_level: Vec = Level::ALL .iter() .map(|l| { let names: Vec<&str> = l.processes().iter().map(|p| p.as_str()).collect(); format!("level {}: {}", l.code(), names.join(", ")) }) .collect(); json!({ "type": "string", "enum": strings(&all), "description": format!( "The specific cognitive operation. Must belong to the item's level — {}.", by_level.join("; ") ) }) } /// The distractor error-type enum, with each gloss in the description. fn error_type() -> Value { let all: Vec<&str> = ErrorType::ALL.iter().map(|e| e.as_str()).collect(); let glosses: Vec = ErrorType::ALL .iter() .map(|e| format!("{} — {}", e.as_str(), e.gloss())) .collect(); json!({ "type": "string", "enum": strings(&all), "description": format!( "What kind of mistake this distractor is designed to catch. {}", glosses.join("; ") ) }) } /// The schema for the `course` identity block. fn course_identity_schema() -> Value { json!({ "type": "object", "required": ["code", "title", "term"], "additionalProperties": false, "properties": { "code": text("Course code, e.g. BIOSC 1540."), "title": text("Course title."), "term": text("Term, e.g., 2026s."), "institution": { "type": "string" }, "instructors": string_array("Instructor names."), "slug": { "type": "string", "description": "Short identifier used in file names and administration ids. \ Derived from the code if omitted." } } }) } /// The schema for the course-wide policy block. fn policy_schema() -> Value { json!({ "type": "object", "additionalProperties": false, "description": "Course-wide defaults and rules that validation enforces.", "properties": { "points_per_item": { "type": "number", "exclusiveMinimum": 0.0, "description": "Default points for an item that does not set its own." }, "options_per_item": { "type": "integer", "minimum": 2, "description": "Expected option count; the linter flags items that differ." }, "bonus_levels": { "type": "array", "items": level(), "description": "Levels a bonus item may be drawn from." }, "allow_partial_credit": { "type": "boolean", "description": "Whether any option may carry partial credit. When false, \ validation rejects items that do." }, "partial_credit_floor_level": level(), "mastery_threshold": proportion( "Rate at which an objective counts as met. 0.75 is a common choice." ), "min_items_for_mastery": { "type": "integer", "minimum": 1, "description": "Below this many items on an objective, reports say 'not enough \ evidence' rather than classifying." }, "grade_scale": { "type": "array", "description": "Letter-grade bands. Only the lower bound of each is recorded; a \ band runs up to the next one. Set this and a class report bins \ scores by letter rather than by ten-point interval.", "items": { "type": "object", "required": ["letter", "min"], "additionalProperties": false, "properties": { "letter": { "type": "string", "description": "The letter as it appears on a transcript." }, "min": { "type": "number", "minimum": 0, "maximum": 100, "description": "Lowest percentage earning this letter, inclusive." }, "gpa": { "type": "number", "minimum": 0, "description": "Grade points the band carries." }, "attainment": { "type": "string", "description": "The attainment word attached to the band." }, "group": { "type": "string", "description": "Colour group for reports; defaults to the letter's \ first character." } } } } } }) } /// The schema for one unit. fn unit_schema() -> Value { json!({ "type": "object", "required": ["id", "title"], "additionalProperties": false, "properties": { "id": text("Unit id."), "title": text("Unit title."), "description": { "type": "string" } } }) } /// The schema for one lecture. fn lecture_schema() -> Value { json!({ "type": "object", "required": ["title"], "additionalProperties": false, "properties": { "title": text("Lecture title."), "date": date("Date delivered."), "unit": { "type": "string", "description": "Unit id." }, "slides_url": { "type": "string" }, "readings": { "type": "array", "description": "Readings assigned with this lecture, in the order you assign \ them. A plain string is the pre-schema form and still loads.", "items": reading_schema() } } }) } /// The schema for one learning objective. fn objective_schema() -> Value { json!({ "type": "object", "required": ["text"], "additionalProperties": false, "properties": { "text": text("The objective as a student would read it. Start with a verb."), "unit": { "type": "string" }, "lectures": string_array("Lecture ids that cover this."), "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." }, "level_ceiling": level(), "prerequisites": string_array( "Objective ids that must come first. Cycles are rejected." ), "tags": string_array("Free-form tags."), "assessed": { "type": "boolean", "description": "Set false for an objective you teach but do not test; coverage \ reporting will stop flagging it as a gap." } } }) } /// The schema for one cited work. fn reference_schema() -> Value { json!({ "type": "object", "required": ["title"], "additionalProperties": false, "properties": { "label": text("Short form a reading list shows, such as KKW. One work per label."), "kind": { "type": "string", "enum": strings(&[ "book", "chapter", "article", "preprint", "thesis", "website", "software", "dataset", "video", "other" ]), "description": "Kind of work, following BibTeX entry types." }, "role": { "type": "string", "enum": strings(&["required", "supplemental"]), "description": "required for a course text; supplemental for background." }, "title": text("Full title."), "authors": string_array("Authors as `Family, Given`, in printed order."), "year": { "type": "integer", "description": "Year of publication." }, "edition": { "type": "string", "description": "Edition as printed: 7th." }, "publisher": { "type": "string" }, "container": { "type": "string", "description": "Journal, edited volume, or series." }, "volume": { "type": "string" }, "issue": { "type": "string" }, "pages": { "type": "string", "description": "Pages of the work, not of a reading." }, "doi": { "type": "string", "description": "Bare DOI: 10.1038/nature12373." }, "isbn": { "type": "string" }, "url": { "type": "string", "description": "Canonical URL for the whole work." }, "base_url": { "type": "string", "description": "Prefix a reading's `path` is joined to, so the citation key \ appears once instead of once per reading." }, "note": { "type": "string", "description": "Access notes: reserve shelf, license." } } }) } /// The schema for one reading: a location inside a reference, and what it is for. fn reading_schema() -> Value { json!({ "oneOf": [ { "type": "string", "description": "The pre-schema form: a citation, unparsed." }, reading_mapping_schema() ] }) } /// The mapping form of a reading. fn reading_mapping_schema() -> Value { json!({ "type": "object", "required": ["ref"], "additionalProperties": false, "properties": { "ref": text("Citation key into `references`."), "locator": text("Where inside the work: §6.1, pp. 212-219, ch. 3."), "path": { "type": "string", "description": "Joined to the reference's base_url to reach this location." }, "url": { "type": "string", "description": "Full URL, when base_url does not cover the location." }, "role": { "type": "string", "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." ), "summary": text("What the section contains."), "focus": text("What to take from it. This is the sentence a student report quotes."), "skip": text("What to gloss, and why it is out of scope."), "text": text("A pre-schema citation string, held unparsed.") } }) } /// The schema for one shared stimulus. fn stimulus_schema() -> Value { json!({ "type": "object", "required": ["body"], "additionalProperties": false, "properties": { "body": text("The stimulus text, in coursebank markup."), "asset": { "type": "string", "description": "Path to an image." }, "caption": { "type": "string" } } }) } /// The course schema. fn course_schema() -> Value { json!({ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": format!("{BASE}/course.schema.json"), "title": "coursebank course file", "description": "Course identity, policy, and the registries that item and assessment \ files reference by id.", "type": "object", "required": ["course"], "additionalProperties": false, "properties": { "schema_version": { "type": ["string", "number"], "description": format!("Format version; currently {SCHEMA_VERSION}.") }, "course": course_identity_schema(), "policy": policy_schema(), "units": { "type": "array", "description": "Course units, in teaching order. That order drives report layout.", "items": unit_schema() }, "lectures": { "type": "object", "description": "Lectures by id. Items cite these so reports can tell a student \ where to go back to.", "additionalProperties": lecture_schema() }, "learning_objectives": { "type": "object", "description": "Objectives by id. Everything downstream — coverage, mastery, \ student reports — keys off these.", "additionalProperties": objective_schema() }, "stimuli": { "type": "object", "description": "Shared passages, figures, or data that several items refer to.", "additionalProperties": stimulus_schema() }, "references": { "type": "object", "description": "Works the course cites, by citation key. Readings point in \ here, so an edition change is one edit.", "additionalProperties": reference_schema() } } }) } /// One option's schema. /// /// Split out from [`item_schema`] rather than inlined, because `serde_json`'s /// `json!` macro recurses once per nesting level *and* once per key-value pair. A /// single literal describing the whole item exceeded the default recursion limit of /// 128, so each subtree gets its own shallow invocation. Keeping them small also /// means adding a field later cannot silently reintroduce the problem. fn option_schema() -> Value { json!({ "type": "object", "required": ["id", "text"], "additionalProperties": false, "properties": { "id": { "type": "string", "pattern": "^[A-H]$", "description": "Option letter. Identity, not print position — shuffled forms \ relabel on the way out." }, "text": text("The option as a student reads it."), "correct": { "type": "boolean" }, "credit": proportion("Partial credit. Requires defensible: true and a defense."), "explanation": { "type": "string", "description": "Why this option is right or wrong. For you, not the student." }, "hint": { "type": "string" }, "misconception": { "type": "string", "description": "The specific wrong belief that leads here. This text is what \ student reports use, so write it as a completion of 'students \ pick this when ...'." }, "error_type": error_type(), "defensible": { "type": "boolean", "description": "This option has a reading under which it is arguably correct. \ Required before granting partial credit." }, "defense": { "type": "string", "description": "The argument for that reading. Required when defensible is true." }, "feedback_student": { "type": "string", "description": "Shown to a student who chose this, on Canvas and in reports." }, "selection_rate_expected": proportion( "How often you expect this to be chosen. Compared against reality." ) } }) } /// The schema for where an item's material was taught. fn source_schema() -> Value { json!({ "type": "object", "required": ["lecture"], "additionalProperties": false, "properties": { "lecture": text("Lecture id from course.yaml."), "slides": { "type": "array", "items": { "type": "integer", "minimum": 1 } }, "readings": string_array("Specific readings."), "recording_seconds": { "type": "integer", "minimum": 0 } } }) } /// The schema for an attached figure. fn asset_schema() -> Value { json!({ "type": "object", "required": ["path"], "additionalProperties": false, "properties": { "path": text("Path to the image, relative to the course root."), "alt": { "type": "string", "description": "Alt text. Required in practice: the linter flags an asset without \ it, because an exam question a screen reader cannot convey is not \ answerable." }, "caption": { "type": "string" } } }) } /// The worked solution, and for an open-response item how it is graded. fn solution_schema() -> Value { json!({ "type": "object", "additionalProperties": false, "description": "The answer, the reasoning, and the rubric. Rendered in the solutions \ document and the answer key, never in a question paper.", "properties": { "model_answer": { "type": "string", "description": "For an open-response item, the response a full-credit student \ writes; for a choice item, an optional one-line statement of the key." }, "explanation": { "type": "string", "description": "The worked reasoning a student learns from. The body of the \ solutions entry." }, "rubric": { "type": "array", "items": rubric_criterion_schema() }, "accepted": { "type": "array", "items": { "type": "string" }, "description": "Responses a short constructed answer is accepted as." }, "review": { "type": "array", "items": citation_schema(), "description": "Where to look again after missing this item." } } }) } /// One rubric line for an open-response item. fn rubric_criterion_schema() -> Value { json!({ "type": "object", "required": ["description"], "additionalProperties": false, "properties": { "description": text("What earns the points on this line."), "points": { "type": "number", "minimum": 0.0 } } }) } /// A citation into the reference registry, written as an object or a bare string. fn citation_schema() -> Value { json!({ "oneOf": [ { "type": "string", "description": "A citation, unparsed." }, citation_mapping_schema() ] }) } /// The object form of a citation. fn citation_mapping_schema() -> Value { json!({ "type": "object", "additionalProperties": false, "properties": { "ref": text("Citation key into `references`."), "locator": { "type": "string", "description": "Where inside the work: §6.1, pp. 4-9." }, "path": { "type": "string", "description": "Joined to the reference base_url." }, "url": { "type": "string" }, "text": { "type": "string" } } }) } /// The schema for authored design intent. fn design_schema() -> Value { json!({ "type": "object", "additionalProperties": false, "description": "What you expected before giving the item. Kept separate from observed \ statistics so the two can be compared.", "properties": { "expected_difficulty": proportion("Fraction you expect to answer correctly."), "expected_discrimination": { "type": "string", "enum": ["low", "moderate", "high"] }, "expected_time_seconds": { "type": "number", "exclusiveMinimum": 0.0 }, "rationale": { "type": "string", "description": "Why this item exists and what it is meant to catch." } } }) } /// The schema for one option's observed statistics. fn option_stat_schema() -> Value { json!({ "type": "object", "additionalProperties": false, "properties": { "selection_rate": proportion("How often chosen."), "point_biserial": { "type": "number", "minimum": -1.0, "maximum": 1.0 }, "upper_group_rate": proportion("Rate in the top 27%."), "lower_group_rate": proportion("Rate in the bottom 27%.") } }) } /// The schema for fitted IRT parameters. fn irt_schema() -> Value { json!({ "type": "object", "required": ["a", "b"], "additionalProperties": false, "properties": { "model": { "type": "string", "enum": ["rasch", "2pl", "3pl"] }, "a": { "type": "number", "description": "Discrimination." }, "b": { "type": "number", "description": "Difficulty." }, "c": proportion("Lower asymptote, 3PL only."), "se_a": { "type": "number", "minimum": 0.0 }, "se_b": { "type": "number", "minimum": 0.0 }, "n": { "type": "integer", "minimum": 0 }, "bayesian": { "type": "boolean", "description": "Whether priors were used. On a class-sized sample they should be." } } }) } /// The schema for pooled observed statistics. fn calibration_schema() -> Value { let flags: Vec<&str> = Flag::ALL.iter().map(|f| f.as_str()).collect(); json!({ "type": "object", "additionalProperties": false, "description": "Written by `coursebank calibrate`, not by hand. Pooled across \ administrations.", "properties": { "administrations": string_array("Administration ids pooled here."), "updated": date("When calibration last ran."), "fingerprint": { "type": "string", "description": "Hash of what a student saw. When it stops matching the item, these \ statistics describe a different question." }, "n_examinees": { "type": "integer", "minimum": 0 }, "p_value": proportion("Observed proportion correct."), "point_biserial": { "type": "number", "minimum": -1.0, "maximum": 1.0 }, "discrimination_index": { "type": "number", "minimum": -1.0, "maximum": 1.0 }, "mean_response_time_seconds": { "type": "number", "minimum": 0.0 }, "rapid_guess_rate": proportion("Fraction answered faster than readable."), "option_stats": { "type": "object", "additionalProperties": option_stat_schema() }, "irt": irt_schema(), "flags": { "type": "array", "items": { "type": "string", "enum": strings(&flags) } } } }) } /// The schema for a recorded review decision. fn review_schema() -> Value { json!({ "type": "object", "additionalProperties": false, "properties": { "reviewed_by": { "type": "string" }, "reviewed_on": date("Review date."), "action": { "type": "string", "enum": ["keep", "revise", "award_partial_credit", "correct_key", "retire", "monitor"] }, "notes": { "type": "string" } } }) } /// The schema for one revision-history entry. fn history_schema() -> Value { json!({ "type": "object", "required": ["version", "date", "change"], "additionalProperties": false, "properties": { "version": { "type": "integer", "minimum": 1 }, "date": date("When the change was made."), "author": { "type": "string" }, "change": text("What changed and why.") } }) } /// The schema for a retirement record. fn retirement_schema() -> Value { json!({ "type": "object", "required": ["on", "reason"], "additionalProperties": false, "properties": { "on": date("Retirement date."), "reason": text("Why it was retired."), "replaced_by": { "type": "string", "description": "Successor item id." } } }) } /// The identity and classification half of the item schema. /// /// Split from [`item_content_properties`] purely to keep each `json!` invocation /// short; the two are merged into one `properties` object by [`item_schema`]. 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." }, "version": { "type": "integer", "minimum": 1, "description": "Bump when you change what a student sees. Recorded on every \ administration so drift is detectable." }, "status": { "type": "string", "enum": ["draft", "in_review", "needs_revision", "approved", "retired"], "description": "Only approved items can be drawn into an assessment." }, "level": level(), "cognitive_process": cognitive_process(), "format": { "type": "string", "enum": ["single_best_answer", "multiple_response", "true_false", "open_response"], "description": "single_best_answer keys exactly one option; multiple_response keys \ two or more; open_response takes no options and is graded from its \ solution." }, "bonus": { "type": "boolean" }, "points": { "type": "number", "exclusiveMinimum": 0.0 }, "author": { "type": "string" }, "notes_private": { "type": "string", "description": "Never exported anywhere a student can see." } }) } /// The content and evidence half of the item schema. fn item_content_properties() -> Value { json!({ "title": { "type": "string", "description": "Short internal label. Never shown to students." }, "stimulus": { "type": "string", "description": "Stimulus id from course.yaml." }, "stem": text( "The question. Ask something specific; the linter flags stems with no task in them." ), "options": { "type": "array", "minItems": 2, "maxItems": 8, "description": "Absent for an open_response item; at least two for any choice format.", "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." ), "sources": { "type": "array", "description": "Where the material was taught. Drives the 'review this' lines in \ student reports.", "items": source_schema() }, "topics": string_array("Free-form topics, used for blueprint filtering."), "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", "description": "One entry per version. Versions must increase.", "items": history_schema() }, "retired": retirement_schema() }) } /// The item schema fragment, shared by the bank schema. /// /// Assembled from the helpers above rather than written as one literal. See /// [`option_schema`] for why. fn item_schema() -> Value { let mut properties = serde_json::Map::new(); for half in [item_identity_properties(), item_content_properties()] { if let Value::Object(map) = half { properties.extend(map); } } json!({ "type": "object", "required": ["id", "level", "stem"], "additionalProperties": false, "properties": Value::Object(properties) }) } /// The schema for a bank's `bank` metadata block. fn bank_meta_schema() -> Value { json!({ "type": "object", "required": ["id", "title"], "additionalProperties": false, "properties": { "id": text("Bank id, unique within the course."), "title": text("Human-readable title."), "description": { "type": "string" }, "scope": bank_scope_schema() } }) } /// The schema for what a bank is meant to cover. fn bank_scope_schema() -> Value { json!({ "type": "object", "additionalProperties": false, "description": "What this bank is meant to cover. Validation warns when an item strays \ outside it.", "properties": { "lectures": string_array("Lecture ids."), "learning_objectives": string_array("Objective ids."), "units": string_array("Unit ids."), "topics": string_array("Topics.") } }) } /// The schema for per-file item defaults. fn bank_defaults_schema() -> Value { json!({ "type": "object", "additionalProperties": false, "description": "Applied to every item in the file that does not set the field. Saves \ repeating yourself; the item always wins.", "properties": { "author": { "type": "string" }, "points": { "type": "number", "exclusiveMinimum": 0.0 }, "options_per_item": { "type": "integer", "minimum": 2 }, "topics": string_array("Topics added to every item."), "lectures": string_array("Lecture ids for items with no sources of their own.") } }) } /// The bank schema. fn bank_schema() -> Value { json!({ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": format!("{BASE}/bank.schema.json"), "title": "coursebank item bank", "description": "A collection of items. Split banks by unit or topic; ids must be unique \ across the whole course, not just within a file.", "type": "object", "required": ["bank", "items"], "additionalProperties": false, "properties": { "schema_version": { "type": ["string", "number"] }, "bank": bank_meta_schema(), "defaults": bank_defaults_schema(), "items": { "type": "array", "items": item_schema() } } }) } /// The schema for the `assessment` metadata block. fn assessment_meta_schema() -> Value { json!({ "type": "object", "required": ["id", "title"], "additionalProperties": false, "properties": { "id": text("Assessment id, e.g. exam-4. Used in administration ids."), "title": text("Printed title."), "term": { "type": "string", "description": "Defaults to the course term." }, "kind": { "type": "string", "enum": ["exam", "quiz", "homework", "practice", "final"], "description": "practice is excluded from calibration by default, since \ conditions differ too much to pool." }, "date": date("Administration date. Drives reuse cooldowns."), "platform": { "type": "string", "enum": ["paper", "canvas", "other"] }, "minutes_allowed": { "type": "number", "exclusiveMinimum": 0.0 }, "attempts": { "type": "integer", "description": "Canvas attempt limit; -1 for unlimited." }, "shuffle": { "type": "boolean" }, "scoring_policy": { "type": "string", "enum": ["keep_highest", "keep_latest"] }, "instructions": { "type": "string" }, "notes": { "type": "string" } } }) } /// The schema for the blueprint an assessment was drawn to. fn blueprint_schema() -> Value { json!({ "type": "object", "additionalProperties": false, "description": "The design the form was drawn to satisfy. Kept so the form can be checked \ against the intent afterward.", "properties": { "level_counts": { "type": "object", "description": "How many scored items at each level, keyed by level number.", "additionalProperties": { "type": "integer", "minimum": 0 } }, "bonus_counts": { "type": "object", "additionalProperties": { "type": "integer", "minimum": 0 } }, "objective_minimums": { "type": "object", "description": "Minimum items per objective. Placed before level quotas, because \ a coverage requirement is the constraint most likely to become \ unsatisfiable.", "additionalProperties": { "type": "integer", "minimum": 0 } }, "lectures": string_array("Restrict the draw to these lectures."), "topics": string_array("Restrict the draw to these topics."), "banks": string_array("Restrict the draw to these banks."), "max_per_bank": { "type": "integer", "minimum": 1 }, "cooldown_days": { "type": "integer", "minimum": 0, "description": "Avoid items used within this many days. Relaxed with a warning \ rather than failing the draw." }, "seed": { "type": "integer", "minimum": 0, "description": "Makes the draw reproducible." } } }) } /// The schema for one alternate form. fn form_schema() -> Value { json!({ "type": "object", "required": ["id", "seed"], "additionalProperties": false, "properties": { "id": text("Form label, e.g. A."), "seed": { "type": "integer", "minimum": 0 }, "shuffle_items": { "type": "boolean" }, "shuffle_options": { "type": "boolean" } } }) } /// The schema for one question placement. fn placement_schema() -> Value { json!({ "type": "object", "required": ["number", "item"], "additionalProperties": false, "properties": { "number": { "type": "integer", "minimum": 1, "description": "The question number as administered. This is the join key to \ Gradescope and Canvas exports, so it must not change after the \ fact." }, "item": text("Item reference, `bank::item-id` or a bare item id."), "version": { "type": "integer", "minimum": 1 }, "fingerprint": { "type": "string", "description": "What the item looked like when given. Validation warns if the item \ has since changed." }, "points": { "type": "number", "minimum": 0.0 }, "bonus": { "type": "boolean" }, "key": string_array("Keyed option letters as administered."), "level": level(), "learning_objectives": string_array("Objectives as administered."), "credit_overrides": { "type": "object", "description": "Partial credit decided after the fact, by option letter. Recording \ it here keeps the rescoring decision with the administration it \ applies to.", "additionalProperties": { "type": "number", "minimum": 0.0, "maximum": 1.0 } }, "dropped": { "type": "boolean", "description": "Excluded from scoring and from statistics." } } }) } /// The assessment schema. fn assessment_schema() -> Value { json!({ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": format!("{BASE}/assessment.schema.json"), "title": "coursebank assessment record", "description": "A record of what was given, to whom, and when. This is the join between \ item banks and grading data, and it is the source of truth for reuse \ history — there is no separate ledger to drift out of step.", "type": "object", "required": ["assessment"], "additionalProperties": false, "properties": { "schema_version": { "type": ["string", "number"] }, "assessment": assessment_meta_schema(), "blueprint": blueprint_schema(), "forms": { "type": "array", "description": "Alternate forms. Option order is derived from the seed rather than \ stored, so every export of a form agrees.", "items": form_schema() }, "items": { "type": "array", "description": "One entry per question, in number order.", "items": placement_schema() } } }) } #[cfg(test)] mod tests { use super::*; #[test] fn every_schema_is_well_formed() { for kind in Kind::ALL { let s = schema(kind); assert!(s["$schema"].is_string(), "{kind:?} needs a $schema"); assert!(s["$id"].is_string(), "{kind:?} needs an $id"); assert_eq!(s["type"], "object"); assert!( s["additionalProperties"] == false, "{kind:?} must reject unknown keys, matching the Rust deserializer" ); // Round-trips as JSON. let text = serde_json::to_string(&s).unwrap(); let back: Value = serde_json::from_str(&text).unwrap(); assert_eq!(back, s); } } #[test] fn the_level_enum_lists_all_five() { let l = level(); assert_eq!(l["minimum"], 1); assert_eq!(l["maximum"], 5); let description = l["description"].as_str().unwrap(); for level in Level::ALL { assert!( description.contains(level.name()), "missing {}", level.name() ); } } #[test] fn the_process_enum_matches_the_taxonomy() { let p = cognitive_process(); let listed = p["enum"].as_array().unwrap(); assert_eq!(listed.len(), CognitiveProcess::ALL.len()); assert!(listed.contains(&Value::String("differentiate".into()))); } #[test] fn the_item_schema_constrains_ids_and_options() { let item = item_schema(); let props = &item["properties"]; assert!(props["id"]["pattern"].as_str().unwrap().starts_with("^q-")); assert_eq!(props["options"]["minItems"], 2); assert_eq!(props["options"]["maxItems"], 8); assert_eq!( props["options"]["items"]["properties"]["id"]["pattern"], "^[A-H]$" ); } #[test] fn modelines_point_at_the_right_file() { assert_eq!( Kind::Bank.modeline("../.coursebank/schema"), "# yaml-language-server: $schema=../.coursebank/schema/bank.schema.json" ); // A trailing slash must not double up. assert!( Kind::Course .modeline("schema/") .ends_with("schema/course.schema.json") ); } #[test] fn schemas_write_to_disk() { let dir = std::env::temp_dir().join(format!("cb-schema-{}", std::process::id())); std::fs::remove_dir_all(&dir).ok(); let written = write_all(&dir).unwrap(); assert_eq!(written.len(), 3); for path in &written { assert!(path.exists()); let text = std::fs::read_to_string(path).unwrap(); let _: Value = serde_json::from_str(&text).expect("valid JSON on disk"); } std::fs::remove_dir_all(&dir).ok(); } }