From 468af3a815027b07b6db5bcc1da4a5df99fafdf6 Mon Sep 17 00:00:00 2001
From: Alex Maldonado
Date: Sat, 8 Aug 2026 13:06:48 -0400
Subject: [PATCH 01/26] fix: merge .gitignore instead of overwrite
---
src/commands/project.rs | 225 +++++++++++++++++++++++++++++++++++++++-
1 file changed, 223 insertions(+), 2 deletions(-)
diff --git a/src/commands/project.rs b/src/commands/project.rs
index f7deb8b..6b6317d 100644
--- a/src/commands/project.rs
+++ b/src/commands/project.rs
@@ -9,7 +9,9 @@
//! checking — [`validate`] for problems that must be fixed and [`lint`] for
//! item-writing guidance. [`catalog`] summarizes the pool that results.
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, BTreeSet};
+use std::fs;
+use std::path::Path;
use coursebank::assessment::AssessmentFile;
use coursebank::bank::BankFile;
@@ -44,6 +46,9 @@ reports/
*.swp
";
+/// Marks the block `init` prepends to a `.gitignore` that was already there.
+const GITIGNORE_HEADER: &str = "# Added by coursebank init.";
+
/// `init`: create a new course directory, refusing to clobber an existing one.
pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result {
let layout = Layout::new(&cli.course);
@@ -70,7 +75,7 @@ pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result {
println!("wrote {}", bank_path.display());
}
- yaml::write_text(&cli.course.join(".gitignore"), GITIGNORE)?;
+ write_gitignore(&cli.course.join(".gitignore"))?;
println!(
"\nNext: edit {} to add your learning objectives and lectures, then\n \
coursebank bank new unit-1 --title \"Unit 1\"\n coursebank validate",
@@ -79,6 +84,152 @@ pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result {
Ok(Outcome::Ok)
}
+/// What reconciling [`GITIGNORE`] against a file already on disk would do.
+struct GitignoreMerge {
+ /// The file to write. Identical to the input when nothing was missing.
+ text: String,
+ /// The patterns that were missing, in the order [`GITIGNORE`] lists them.
+ added: Vec,
+ /// Patterns the file un-ignores with a `!` rule, which are left out. Git
+ /// applies the last matching rule, so a line added at the top would lose.
+ negated: Vec,
+}
+
+/// The comparison key for one `.gitignore` line, or `None` for a blank or comment.
+///
+/// `build`, `build/`, and `/build/` are one pattern spelled three ways, so the key
+/// drops the slashes. A leading `!` stays, because `!build` is the opposite of
+/// `build` rather than a restatement of it.
+fn pattern_key(line: &str) -> Option {
+ let trimmed = line.trim();
+ if trimmed.is_empty() || trimmed.starts_with('#') {
+ return None;
+ }
+ let (bang, rest) = match trimmed.strip_prefix('!') {
+ Some(rest) => ("!", rest.trim_start()),
+ None => ("", trimmed),
+ };
+ let rest = rest.trim_start_matches('/').trim_end_matches('/');
+ if rest.is_empty() {
+ return None;
+ }
+ Some(format!("{bang}{rest}"))
+}
+
+/// Reconciles [`GITIGNORE`] against a `.gitignore` that is already on disk.
+///
+/// Patterns the file already has are skipped, and a block of [`GITIGNORE`] left
+/// with no patterns loses its comment too, so nobody ends up with a heading over
+/// nothing. What survives goes above the existing content, which is copied
+/// through unchanged, including its line endings.
+fn merge_gitignore(existing: &str) -> GitignoreMerge {
+ let keys: BTreeSet = existing.lines().filter_map(pattern_key).collect();
+ let crlf = existing.contains("\r\n");
+ let newline = if crlf { "\r\n" } else { "\n" };
+
+ let mut block: Vec<&str> = Vec::new();
+ let mut pending: Vec<&str> = Vec::new();
+ let mut added: Vec = Vec::new();
+ let mut negated: Vec = Vec::new();
+ let mut kept_in_block = false;
+
+ for line in GITIGNORE.lines() {
+ let trimmed = line.trim();
+ if trimmed.is_empty() {
+ // A blank line starts a new block, so any comment still waiting for a
+ // pattern belonged to a block that was dropped entirely.
+ pending.clear();
+ kept_in_block = false;
+ continue;
+ }
+ if trimmed.starts_with('#') {
+ pending.push(trimmed);
+ continue;
+ }
+ let Some(key) = pattern_key(trimmed) else {
+ continue;
+ };
+ if keys.contains(&key) {
+ continue;
+ }
+ if keys.contains(&format!("!{key}")) {
+ negated.push(trimmed.to_string());
+ continue;
+ }
+ if !kept_in_block && !block.is_empty() {
+ block.push("");
+ }
+ block.append(&mut pending);
+ kept_in_block = true;
+ block.push(trimmed);
+ added.push(trimmed.to_string());
+ }
+
+ let mut text = String::new();
+ if !block.is_empty() {
+ for line in std::iter::once(GITIGNORE_HEADER).chain(block).chain([""]) {
+ text.push_str(line);
+ text.push_str(newline);
+ }
+ }
+ text.push_str(existing);
+
+ GitignoreMerge {
+ text,
+ added,
+ negated,
+ }
+}
+
+/// Writes the `.gitignore`, merging into one that is already there.
+///
+/// `init` is often run in a repository that already has a `.gitignore`.
+/// An existing file keeps everything it had and gains only the patterns
+/// it was missing, at the top where they are easy to see in the diff.
+fn write_gitignore(path: &Path) -> Result<()> {
+ let existing = match fs::read_to_string(path) {
+ Ok(text) => text,
+ // Nothing to merge with. Stay quiet about it.
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
+ return yaml::write_text(path, GITIGNORE);
+ }
+ // A file that is there but unreadable, or not UTF-8, is not one to
+ // replace on a guess.
+ Err(e) => return Err(Error::io(path, e)),
+ };
+
+ let merge = merge_gitignore(&existing);
+ if merge.added.is_empty() {
+ println!(
+ "{} already has every pattern init would add",
+ path.display()
+ );
+ } else {
+ yaml::write_text(path, &merge.text)?;
+ println!(
+ "added {} pattern(s) to the top of {}: {}",
+ merge.added.len(),
+ path.display(),
+ merge.added.join(" ")
+ );
+ }
+
+ for pattern in &merge.negated {
+ println!(
+ "note: {} un-ignores `{pattern}`, and the last matching rule wins, \
+ so init did not add it",
+ path.display()
+ );
+ if pattern.contains("salt") {
+ println!(
+ " that rule will commit the pseudonymization salt; \
+ remove it before you push"
+ );
+ }
+ }
+ Ok(())
+}
+
/// `schema`: (re)write the JSON Schemas an editor uses to validate the YAML.
pub(crate) fn schema(cli: &Cli) -> Result {
let layout = Layout::new(&cli.course);
@@ -271,4 +422,74 @@ mod tests {
assert!(GITIGNORE.contains(".coursebank-salt"));
assert!(GITIGNORE.contains("build/"));
}
+
+ #[test]
+ fn merging_keeps_the_existing_file_and_adds_only_what_was_missing() {
+ let existing = "# rules I wrote\ntarget/\nbuild/\n*.pdf\n";
+ let merge = merge_gitignore(existing);
+
+ assert!(
+ merge.text.ends_with(existing),
+ "the existing file must survive byte for byte:\n{}",
+ merge.text
+ );
+ assert!(merge.text.starts_with(GITIGNORE_HEADER));
+ assert!(!merge.added.iter().any(|p| p == "build/" || p == "*.pdf"));
+ assert!(merge.added.iter().any(|p| p == "reports/"));
+ assert!(merge.added.iter().any(|p| p == ".coursebank-salt"));
+ }
+
+ #[test]
+ fn a_block_with_nothing_left_to_add_loses_its_comment() {
+ let merge = merge_gitignore("build/\nreports/\n");
+ assert!(!merge.text.contains("# Generated output"));
+ assert!(merge.text.contains("# Typst and PDF artifacts."));
+ }
+
+ #[test]
+ fn slashes_do_not_make_a_pattern_look_new() {
+ let merge = merge_gitignore("/build\nreports\n/data/\n");
+ assert!(
+ !merge.added.iter().any(|p| p.contains("build")),
+ "`/build` already covers `build/`, so it must not be added again"
+ );
+ assert!(!merge.added.iter().any(|p| p.contains("reports")));
+ }
+
+ #[test]
+ fn a_complete_file_is_left_exactly_as_it_was() {
+ let merge = merge_gitignore(GITIGNORE);
+ assert!(merge.added.is_empty());
+ assert_eq!(merge.text, GITIGNORE);
+ }
+
+ #[test]
+ fn a_negated_pattern_is_reported_instead_of_reinserted() {
+ let merge = merge_gitignore("*.pdf\n!*.salt\n");
+ assert_eq!(merge.negated, vec!["*.salt".to_string()]);
+ assert!(!merge.added.iter().any(|p| p == "*.salt"));
+ // The un-ignore covers one spelling of the salt, not the other.
+ assert!(merge.added.iter().any(|p| p == ".coursebank-salt"));
+ }
+
+ #[test]
+ fn line_endings_follow_the_file_being_merged_into() {
+ let merge = merge_gitignore("target/\r\n");
+ assert_eq!(
+ merge.text.matches('\n').count(),
+ merge.text.matches("\r\n").count(),
+ "a CRLF file must not gain bare LF lines:\n{:?}",
+ merge.text
+ );
+ }
+
+ #[test]
+ fn comments_and_blank_lines_are_not_patterns() {
+ assert_eq!(pattern_key(" build/ ").as_deref(), Some("build"));
+ assert_eq!(pattern_key("/build/").as_deref(), Some("build"));
+ assert_eq!(pattern_key("!build").as_deref(), Some("!build"));
+ assert_eq!(pattern_key("# build/"), None);
+ assert_eq!(pattern_key(" "), None);
+ assert_eq!(pattern_key("/"), None);
+ }
}
--
2.54.0
From f475c630e05390e24c0a2d952f55e844ee57ccf5 Mon Sep 17 00:00:00 2001
From: Alex Maldonado
Date: Sat, 8 Aug 2026 16:24:50 -0400
Subject: [PATCH 02/26] feat: support readings and lecture rendering
---
docs/guide/setup.md | 67 ++++
src/authoring/jsonschema.rs | 107 ++++-
src/cli.rs | 62 +++
src/commands.rs | 4 +
src/commands/lectures.rs | 108 +++++
src/commands/project.rs | 9 +-
src/export.rs | 2 +
src/export/lecture.rs | 414 +++++++++++++++++++
src/lib.rs | 2 +-
src/model/course.rs | 771 +++++++++++++++++++++++++++++++++++-
10 files changed, 1535 insertions(+), 11 deletions(-)
create mode 100644 src/commands/lectures.rs
create mode 100644 src/export/lecture.rs
diff --git a/docs/guide/setup.md b/docs/guide/setup.md
index 12782ec..1a99c21 100644
--- a/docs/guide/setup.md
+++ b/docs/guide/setup.md
@@ -26,8 +26,75 @@ That gives you:
`build/` and `reports/` are in the generated `.gitignore`.
The other four are the repository's content and belong in review.
+If the directory already has a `.gitignore`, `init` keeps it and inserts only the patterns it was missing at the top, so running this inside an existing repository costs you nothing.
+
Add `--with-examples` if you want a filled-in bank to read rather than an empty directory to stare at.
+## Declare your texts once
+
+Every work the course cites goes in `references`, keyed by the citation key you would use in a `.bib` file.
+
+```yaml
+references:
+ kuriyan2013molecules:
+ label: KKW
+ kind: book
+ role: required
+ title: 'The molecules of life: Physical and chemical principles'
+ authors: ['Kuriyan, John', 'Konforti, Boyana', 'Wemmer, David']
+ year: 2013
+ publisher: W. W. Norton & Company
+ base_url: https://library.scient.ing/kuriyan2013molecules/
+ note: On reserve at the Bevier Engineering Library.
+```
+
+`label` is the short form a reading list shows, and it has to name one work, because reports print it instead of the key.
+`base_url` is what a reading's `path` is joined to, so the key appears once in the file rather than once per reading.
+
+## Point readings at objectives
+
+A reading names a location inside a reference and lists the objectives it serves.
+
+```yaml
+lectures:
+ L1.1:
+ title: Enthalpy
+ readings:
+ - ref: kuriyan2013molecules
+ locator: '§1.3'
+ path: '1/A/#3'
+ objectives: [lo-water-attenuation, lo-coulomb-estimate]
+ summary: >-
+ Ionic interactions: favorable in vacuum, attenuated ~80-fold by water.
+ focus: >-
+ The two magnitudes and the factor of 80.
+ skip: >-
+ Skip the unit-conversion derivation.
+```
+
+The three prose fields answer three different questions, and each has a different reader.
+`summary` says what the section contains, `focus` says what to take from it, and `skip` says what to ignore.
+A student report quotes `focus` at somebody who missed the objective; a lecture page prints all three.
+
+The mapping lives on the reading rather than on the objective because objectives outlive editions.
+When a textbook renumbers its sections, one block of `readings` changes and `learning_objectives` does not.
+Going the other way is a scan: `coursebank lecture coverage` lists the readings behind each objective and flags the ones with none.
+
+Set `order` on each objective if you want a lecture page to number them in teaching order.
+The registry is a map, so declaration order is lost on load, and sorting by id would put `lo-enthalpy` ahead of `lo-first-law`.
+
+A reading written as a plain string, which is what this field held before, still loads and is written back out unchanged.
+
+## Generate the reading list
+
+```console
+$ coursebank lecture readings L1.1 --out lectures/l1_1-readings.qmd
+wrote lectures/l1_1-readings.qmd
+```
+
+Objective numbers in the generated page (`_(LO 4, 7)_`) are positional, so they are computed at render time rather than written down.
+Insert an objective and everything after it renumbers on the next build.
+
## Point your editor at the schemas
The schemas are the difference between authoring items and looking up field names.
diff --git a/src/authoring/jsonschema.rs b/src/authoring/jsonschema.rs
index 329ce36..8d4e030 100644
--- a/src/authoring/jsonschema.rs
+++ b/src/authoring/jsonschema.rs
@@ -291,7 +291,12 @@ fn lecture_schema() -> Value {
"date": date("Date delivered."),
"unit": { "type": "string", "description": "Unit id." },
"slides_url": { "type": "string" },
- "readings": string_array("Readings assigned with this lecture.")
+ "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()
+ }
}
})
}
@@ -306,6 +311,13 @@ fn objective_schema() -> Value {
"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."
@@ -320,6 +332,93 @@ fn objective_schema() -> Value {
})
}
+/// 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!({
@@ -373,6 +472,12 @@ fn course_schema() -> Value {
"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()
}
}
})
diff --git a/src/cli.rs b/src/cli.rs
index 26003f7..ac7a66d 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -23,6 +23,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
use coursebank::assessment::{Kind as AssessmentKind, Platform};
use coursebank::catalog::Severity;
use coursebank::item::IrtModel;
+use coursebank::lecture::Style as PageStyle;
use coursebank::store;
/// Manage course item banks, assessments, and the analysis that comes back.
@@ -55,6 +56,9 @@ pub(crate) enum Command {
Lint(LintArgs),
/// Summarize the item pool and objective coverage.
Catalog(CatalogArgs),
+ /// Render a lecture's reading list, or check what backs each objective.
+ #[command(subcommand)]
+ Lecture(LectureCommand),
/// Work with item banks.
#[command(subcommand)]
Bank(BankCommand),
@@ -123,6 +127,64 @@ pub(crate) struct LintArgs {
}
/// CLI mirror of [`coursebank::catalog::Severity`].
+#[derive(Debug, Subcommand)]
+pub(crate) enum LectureCommand {
+ /// Write the readings block for one lecture.
+ ///
+ /// The course file is the source of truth for what a lecture assigns and why,
+ /// so the list on the website is generated from it. Objective numbers are
+ /// positional and are resolved here rather than authored.
+ Readings {
+ /// Lecture id, e.g. L1.1.
+ id: String,
+ /// Which flavour of Markdown to write.
+ #[arg(long, value_enum, default_value = "quarto")]
+ style: StyleArg,
+ /// Output path; prints to stdout when omitted.
+ #[arg(long)]
+ out: Option,
+ },
+ /// Write the objectives block for one lecture, grouped by level.
+ ///
+ /// The numbering comes from the same place as the `_(LO 4, 7)_` lists in
+ /// `readings`, so generating one and hand-writing the other is what this
+ /// exists to prevent.
+ Objectives {
+ /// Lecture id, e.g. L1.1.
+ id: String,
+ /// Which flavour of Markdown to write.
+ #[arg(long, value_enum, default_value = "quarto")]
+ style: StyleArg,
+ /// Output path; prints to stdout when omitted.
+ #[arg(long)]
+ out: Option,
+ },
+ /// Show the readings behind each objective, and which objectives have none.
+ Coverage {
+ /// Only this lecture's objectives.
+ #[arg(long)]
+ lecture: Option,
+ },
+}
+
+#[derive(Debug, Clone, Copy, ValueEnum)]
+pub(crate) enum StyleArg {
+ /// Pandoc definition lists, as a Quarto lecture page wants them.
+ Quarto,
+ /// Plain Markdown bullets.
+ Plain,
+}
+
+impl StyleArg {
+ /// Converts the CLI value into the library's [`PageStyle`].
+ pub(crate) fn as_style(self) -> PageStyle {
+ match self {
+ StyleArg::Quarto => PageStyle::Quarto,
+ StyleArg::Plain => PageStyle::Plain,
+ }
+ }
+}
+
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum SeverityArg {
Low,
diff --git a/src/commands.rs b/src/commands.rs
index e4a7f7d..8b98b7f 100644
--- a/src/commands.rs
+++ b/src/commands.rs
@@ -10,6 +10,8 @@
//!
//! - [`project`] — set up and check a course: `init`, `schema`, `validate`,
//! `lint`, `catalog`.
+//! - [`lectures`] — render a lecture's reading list and check what backs each
+//! objective: `lecture`.
//! - [`banks`] — manage items and build assessments: `bank`, `assessment`,
//! `assemble`, `usage`.
//! - [`export`] — turn an assessment into deliverables: `export`, `template`.
@@ -22,6 +24,7 @@
pub(crate) mod analysis;
pub(crate) mod banks;
pub(crate) mod export;
+pub(crate) mod lectures;
pub(crate) mod project;
use coursebank::error::Result;
@@ -56,6 +59,7 @@ pub(crate) fn run(cli: &Cli) -> Result {
Command::Validate => project::validate(cli),
Command::Lint(args) => project::lint(cli, args),
Command::Catalog(args) => project::catalog(cli, args),
+ Command::Lecture(sub) => lectures::lecture(cli, sub),
Command::Bank(sub) => banks::bank(cli, sub),
Command::Assessment(sub) => banks::assessment(cli, sub),
Command::Assemble(args) => banks::assemble(cli, args),
diff --git a/src/commands/lectures.rs b/src/commands/lectures.rs
new file mode 100644
index 0000000..82f6e64
--- /dev/null
+++ b/src/commands/lectures.rs
@@ -0,0 +1,108 @@
+// SPDX-License-Identifier: Prosperity-3.0.0
+// Copyright Scientific Computing Studio
+// Source: https://git.scient.ing/education/coursebank
+
+//! Rendering lecture pages, and checking what backs each objective.
+//!
+//! Both handlers here read the course file and nothing else, so neither needs a
+//! bank or a single response. That is deliberate: a reading list is useful in week
+//! one, before any item exists.
+
+use coursebank::course::CourseFile;
+use coursebank::error::Result;
+use coursebank::lecture::{objectives_markdown, readings_markdown};
+use coursebank::yaml;
+
+use crate::cli::{Cli, LectureCommand};
+use crate::commands::Outcome;
+
+/// `lecture`: render a reading list, or report reading coverage.
+pub(crate) fn lecture(cli: &Cli, sub: &LectureCommand) -> Result {
+ let course = CourseFile::load_dir(&cli.course)?;
+
+ match sub {
+ LectureCommand::Readings { id, style, out } => emit(
+ readings_markdown(&course, id, style.as_style())?,
+ out.as_deref(),
+ ),
+ LectureCommand::Objectives { id, style, out } => emit(
+ objectives_markdown(&course, id, style.as_style())?,
+ out.as_deref(),
+ ),
+ LectureCommand::Coverage { lecture: only } => coverage(&course, only.as_deref(), cli.quiet),
+ }
+}
+
+/// Writes rendered Markdown to a file, or to stdout when no path was given.
+fn emit(markdown: String, out: Option<&std::path::Path>) -> Result {
+ match out {
+ Some(path) => {
+ yaml::write_text(path, &markdown)?;
+ println!("wrote {}", path.display());
+ }
+ None => print!("{markdown}"),
+ }
+ Ok(Outcome::Ok)
+}
+
+/// Prints the readings behind each objective.
+///
+/// Returns [`Outcome::Findings`] when an assessed objective has no reading, since
+/// that is the case where a student report can name what was missed but not where
+/// to go and read about it.
+fn coverage(course: &CourseFile, lecture: Option<&str>, quiet: bool) -> Result {
+ let ids: Vec = match lecture {
+ Some(l) => course
+ .lecture_objectives(l)
+ .into_iter()
+ .map(str::to_string)
+ .collect(),
+ None => course.objectives_in_order(),
+ };
+
+ for id in &ids {
+ let readings = course.readings_for_objective(id);
+ println!("{id}");
+ if readings.is_empty() {
+ println!(" (no reading)");
+ continue;
+ }
+ for (lecture_id, reading) in readings {
+ let Some(key) = reading.reference.as_deref() else {
+ continue;
+ };
+ let reference = course.reference(key, lecture_id)?;
+ let supplemental = match reading.role {
+ coursebank::course::ReadingRole::Supplemental => " (supplemental)",
+ coursebank::course::ReadingRole::Assigned => "",
+ };
+ println!(
+ " {lecture_id} {}{supplemental}",
+ reading.cite(key, reference)
+ );
+ if let Some(focus) = &reading.focus {
+ println!(
+ " {}",
+ course.expand_objective_refs(focus, |o| { course.objective_text(o) })
+ );
+ }
+ }
+ }
+
+ let gaps = course.objectives_without_readings();
+ if gaps.is_empty() {
+ if !quiet {
+ println!("\nevery assessed objective has a reading behind it");
+ }
+ return Ok(Outcome::Ok);
+ }
+ println!(
+ "\n{} assessed objective(s) with no reading, so a student report cannot say \
+ where to go back to:",
+ gaps.len()
+ );
+ for id in gaps {
+ println!(" - {id}");
+ }
+ Ok(Outcome::Findings)
+}
diff --git a/src/commands/project.rs b/src/commands/project.rs
index 6b6317d..3ace208 100644
--- a/src/commands/project.rs
+++ b/src/commands/project.rs
@@ -183,13 +183,14 @@ fn merge_gitignore(existing: &str) -> GitignoreMerge {
/// Writes the `.gitignore`, merging into one that is already there.
///
-/// `init` is often run in a repository that already has a `.gitignore`.
-/// An existing file keeps everything it had and gains only the patterns
-/// it was missing, at the top where they are easy to see in the diff.
+/// `init` is often run in a repository that already has a `.gitignore`, and the
+/// first version of this overwrote it. An existing file now keeps everything it
+/// had and gains only the patterns it was missing, at the top where they are easy
+/// to see in the diff.
fn write_gitignore(path: &Path) -> Result<()> {
let existing = match fs::read_to_string(path) {
Ok(text) => text,
- // Nothing to merge with. Stay quiet about it.
+ // Nothing to merge with. Stay quiet about it, the way this always has.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return yaml::write_text(path, GITIGNORE);
}
diff --git a/src/export.rs b/src/export.rs
index 2a02566..6de4840 100644
--- a/src/export.rs
+++ b/src/export.rs
@@ -9,6 +9,7 @@
//! | [`qti`] | a QTI 1.2 zip | importing into Canvas |
//! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet |
//! | [`report`] | Markdown and HTML | students, and yourself |
+//! | [`lecture`] | Markdown | the reading list on the course website |
//!
//! [`qti`] and [`typst`] share one rule that is easy to get wrong: a form's answer
//! key must be generated from the same permutation that produced its question
@@ -20,6 +21,7 @@
//! answers, other students' data, and any numeric rank.
//! The instructor report answers "what should I fix?" and holds the item statistics.
+pub mod lecture;
pub mod qti;
pub mod report;
pub mod typst;
diff --git a/src/export/lecture.rs b/src/export/lecture.rs
new file mode 100644
index 0000000..cc18850
--- /dev/null
+++ b/src/export/lecture.rs
@@ -0,0 +1,414 @@
+// SPDX-License-Identifier: Prosperity-3.0.0
+// Copyright Scientific Computing Studio
+// Source: https://git.scient.ing/education/coursebank
+
+//! Rendering a lecture's objectives and readings as Markdown.
+//!
+//! The course file is the source of truth for what a lecture assigns and why, so
+//! the reading list on the course website is generated rather than kept in step by
+//! hand. Two copies of the same prose drift within a term; one copy and a build
+//! step do not.
+//!
+//! [`Style::Quarto`] reproduces the definition-list shape a Quarto page wants,
+//! with `_(LO 4, 7)_` numbering resolved from [`CourseFile::lecture_objectives`].
+//! Those numbers are positional and so cannot be authored: inserting an objective
+//! renumbers everything after it. They are computed here and never stored.
+//!
+//! What this module does not do is invent prose. Everything printed comes from
+//! `summary`, `focus`, and `skip` on the reading, in that order, and a reading with
+//! none of the three renders as a bare citation.
+
+use crate::course::{CourseFile, Reading, ReadingRole, Reference};
+use crate::error::{Error, Result};
+use crate::taxonomy::Level;
+
+/// Which flavour of Markdown to emit.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum Style {
+ /// Pandoc definition lists with ` ` before the objective line, which is
+ /// what a Quarto lecture page uses.
+ #[default]
+ Quarto,
+ /// Plain Markdown bullets, for a report or a README.
+ Plain,
+}
+
+/// Renders the objectives for one lecture, grouped by level.
+///
+/// The numbering here and the `_(LO 4, 7)_` lists in [`readings_markdown`] come
+/// from the same call to [`CourseFile::lecture_objectives`], so they cannot
+/// disagree. Generating one half of the page and hand-writing the other is how you
+/// get a note pointing at LO 8 when LO 8 has become LO 9.
+///
+/// # Arguments
+///
+/// * `course` - the loaded course file.
+/// * `lecture` - the lecture id.
+/// * `style` - which flavour to emit.
+///
+/// # Returns
+///
+/// The Markdown, ending in a newline. Objectives with no `level_ceiling` are
+/// grouped last under no heading.
+///
+/// # Errors
+///
+/// Returns [`Error::Unresolved`] when the lecture id is not registered.
+pub fn objectives_markdown(course: &CourseFile, lecture: &str, style: Style) -> Result {
+ course.lecture(lecture, "lecture page")?;
+ let ids = course.lecture_objectives(lecture);
+
+ let mut out = String::from("## Learning objectives\n\n");
+ out.push_str("After this lecture, you should be able to do the following.\n\n");
+
+ // Levels in taxonomy order, then whatever declares no ceiling.
+ let mut groups: Vec<(Option, Vec<&str>)> =
+ Level::ALL.iter().map(|l| (Some(*l), Vec::new())).collect();
+ groups.push((None, Vec::new()));
+ for id in &ids {
+ let ceiling = course.learning_objectives[*id].level_ceiling;
+ if let Some(slot) = groups.iter_mut().find(|(level, _)| *level == ceiling) {
+ slot.1.push(id);
+ }
+ }
+
+ for (level, members) in &groups {
+ if members.is_empty() {
+ continue;
+ }
+ if let Some(level) = level {
+ out.push_str(&format!("### {}\n\n", level.name()));
+ }
+ for id in members {
+ let text = course.objective_text(id);
+ out.push_str(&match style {
+ Style::Quarto => format!("(@) {text}\n"),
+ Style::Plain => format!("1. {text}\n"),
+ });
+ }
+ out.push('\n');
+ }
+ Ok(out)
+}
+
+/// Renders the readings for one lecture.
+///
+/// # Arguments
+///
+/// * `course` - the loaded course file.
+/// * `lecture` - the lecture id, such as `L1.1`.
+/// * `style` - which flavour to emit.
+///
+/// # Returns
+///
+/// The Markdown, ending in a newline. Supplemental readings follow the assigned
+/// ones under their own subheading, and are omitted entirely when there are none.
+///
+/// # Errors
+///
+/// Returns [`Error::Unresolved`] when the lecture id or a cited reference is not
+/// registered.
+pub fn readings_markdown(course: &CourseFile, lecture: &str, style: Style) -> Result {
+ let lec = course.lecture(lecture, "lecture page")?;
+
+ // Positional numbers for this page, so `{lo-id}` in a note and the trailing
+ // `_(LO ...)_` agree with the objective list printed above them.
+ let order = course.lecture_objectives(lecture);
+ let number = |id: &str| order.iter().position(|o| *o == id).map(|i| i + 1);
+
+ let mut out = String::from("## Readings\n\n");
+ for role in [ReadingRole::Assigned, ReadingRole::Supplemental] {
+ let group: Vec<&Reading> = lec.readings.iter().filter(|r| r.role == role).collect();
+ if group.is_empty() {
+ continue;
+ }
+ if role == ReadingRole::Supplemental {
+ out.push_str("### Supplemental\n\n");
+ }
+ for reading in group {
+ out.push_str(&entry(course, reading, style, &number)?);
+ }
+ }
+ Ok(out)
+}
+
+/// Renders one reading.
+///
+/// # Arguments
+///
+/// * `course` - the course, for resolving references and placeholders.
+/// * `reading` - the reading.
+/// * `style` - which flavour to emit.
+/// * `number` - the position of an objective on this page, if it has one.
+///
+/// # Returns
+///
+/// The entry, followed by a blank line.
+///
+/// # Errors
+///
+/// Returns [`Error::Unresolved`] when the cited reference is not registered.
+fn entry(
+ course: &CourseFile,
+ reading: &Reading,
+ style: Style,
+ number: &impl Fn(&str) -> Option,
+) -> Result {
+ // A reading carried over from the old string form has nothing to resolve.
+ if let (None, Some(text)) = (&reading.reference, &reading.text) {
+ return Ok(match style {
+ Style::Quarto => format!("{text}\n\n"),
+ Style::Plain => format!("- {text}\n"),
+ });
+ }
+ let key = reading
+ .reference
+ .as_deref()
+ .ok_or_else(|| Error::other("reading has neither a reference nor text"))?;
+ let reference = course.reference(key, "lecture page")?;
+
+ let mut out = String::new();
+ out.push_str(&heading(reading, key, reference, style));
+
+ // The three prose fields in the order a reader wants them: what it is, what to
+ // take from it, what to leave.
+ let body: Vec = [&reading.summary, &reading.focus, &reading.skip]
+ .into_iter()
+ .flatten()
+ .map(|prose| {
+ course.expand_objective_refs(prose, |id| match number(id) {
+ Some(n) => format!("LO {n}"),
+ None => course.objective_text(id),
+ })
+ })
+ .collect();
+
+ match style {
+ Style::Quarto => {
+ if !body.is_empty() {
+ out.push_str(&format!(": {}\n", body.join("\n")));
+ }
+ let mut numbers: Vec = reading
+ .objectives
+ .iter()
+ .filter_map(|o| number(o))
+ .collect();
+ numbers.sort_unstable();
+ if !numbers.is_empty() {
+ let list: Vec = numbers.iter().map(|n| n.to_string()).collect();
+ out.push_str(&format!(" \n_(LO {})_\n", list.join(", ")));
+ }
+ out.push('\n');
+ }
+ Style::Plain => {
+ if !body.is_empty() {
+ out.push_str(&format!(" {}\n", body.join(" ")));
+ }
+ }
+ }
+ Ok(out)
+}
+
+/// The citation line that opens an entry.
+///
+/// # Arguments
+///
+/// * `reading` - the reading.
+/// * `key` - its citation key.
+/// * `reference` - the cited work.
+/// * `style` - which flavour to emit.
+///
+/// # Returns
+///
+/// A linked citation when the location has a URL, and a plain one when it does not.
+fn heading(reading: &Reading, key: &str, reference: &Reference, style: Style) -> String {
+ let label = reference.label.as_deref().unwrap_or(key);
+ let locator = reading.locator.as_deref().unwrap_or("");
+ let linked = match reading.resolve_url(reference) {
+ Some(url) if !locator.is_empty() => format!("[{locator}]({url})"),
+ Some(url) => format!("[{}]({url})", reference.title),
+ None if !locator.is_empty() => locator.to_string(),
+ None => reference.title.clone(),
+ };
+ match style {
+ Style::Quarto => format!("`{label}` {linked}\n"),
+ Style::Plain => format!("- **{label}** {linked}\n"),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::course::{Lecture, Objective, ReferenceRole};
+
+ /// A course with one lecture, two objectives, and one reference.
+ fn course() -> CourseFile {
+ let mut c = CourseFile::skeleton("BIOSC 1000", "Biochemistry", "2026f");
+ c.lectures.clear();
+ c.learning_objectives.clear();
+
+ c.references.insert(
+ "kuriyan2013molecules".into(),
+ Reference {
+ label: Some("KKW".into()),
+ role: ReferenceRole::Required,
+ title: "The molecules of life".into(),
+ base_url: Some("https://example.org/kkw/".into()),
+ ..Reference::default()
+ },
+ );
+ for (id, order) in [("lo-second", 2), ("lo-first", 1)] {
+ c.learning_objectives.insert(
+ id.into(),
+ Objective {
+ text: format!("objective {id}"),
+ lectures: vec!["L1.1".into()],
+ order: Some(order),
+ ..objective_defaults()
+ },
+ );
+ }
+ c.lectures.insert(
+ "L1.1".into(),
+ Lecture {
+ title: "Enthalpy".into(),
+ date: None,
+ unit: None,
+ slides_url: None,
+ readings: vec![
+ Reading {
+ reference: Some("kuriyan2013molecules".into()),
+ locator: Some("§6.1".into()),
+ path: Some("6/A/#1".into()),
+ objectives: vec!["lo-second".into(), "lo-first".into()],
+ summary: Some("What a system is.".into()),
+ focus: Some("A worked instance of {lo-first}.".into()),
+ ..Reading::default()
+ },
+ Reading {
+ reference: Some("kuriyan2013molecules".into()),
+ locator: Some("§1.9".into()),
+ path: Some("1/B/#9".into()),
+ role: ReadingRole::Supplemental,
+ objectives: vec!["lo-second".into()],
+ summary: Some("Background.".into()),
+ ..Reading::default()
+ },
+ ],
+ },
+ );
+ c
+ }
+
+ /// The non-defaulted half of an objective, so the fixtures stay short.
+ fn objective_defaults() -> Objective {
+ Objective {
+ text: String::new(),
+ unit: None,
+ lectures: Vec::new(),
+ order: None,
+ level_ceiling: None,
+ prerequisites: Vec::new(),
+ tags: Vec::new(),
+ assessed: true,
+ }
+ }
+
+ #[test]
+ fn the_quarto_form_matches_the_page_it_replaces() {
+ let md = readings_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
+ let expected = "\
+## Readings
+
+`KKW` [§6.1](https://example.org/kkw/6/A/#1)
+: What a system is.
+A worked instance of LO 1.
+
+_(LO 1, 2)_
+
+### Supplemental
+
+`KKW` [§1.9](https://example.org/kkw/1/B/#9)
+: Background.
+
+_(LO 2)_
+
+";
+ assert_eq!(md, expected);
+ }
+
+ #[test]
+ fn objective_numbers_follow_teaching_order_not_id_order() {
+ // `lo-second` sorts first alphabetically and second by `order`.
+ let md = readings_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
+ assert!(md.contains("_(LO 1, 2)_"));
+ assert!(md.contains("A worked instance of LO 1."));
+ }
+
+ #[test]
+ fn objectives_group_by_level_in_taxonomy_order() {
+ let mut c = course();
+ c.learning_objectives
+ .get_mut("lo-first")
+ .expect("fixture")
+ .level_ceiling = Some(Level::Remember);
+ c.learning_objectives
+ .get_mut("lo-second")
+ .expect("fixture")
+ .level_ceiling = Some(Level::Apply);
+ let md = objectives_markdown(&c, "L1.1", Style::Quarto).expect("renders");
+ let expected = "\
+## Learning objectives
+
+After this lecture, you should be able to do the following.
+
+### Remember
+
+(@) objective lo-first
+
+### Apply
+
+(@) objective lo-second
+
+";
+ assert_eq!(md, expected);
+ }
+
+ #[test]
+ fn an_objective_with_no_level_still_appears() {
+ // Ungrouped, at the end, rather than silently dropped.
+ let md = objectives_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
+ assert!(md.contains("(@) objective lo-first"));
+ assert!(md.contains("(@) objective lo-second"));
+ assert!(!md.contains("###"));
+ }
+
+ #[test]
+ fn a_supplemental_heading_appears_only_when_something_is_under_it() {
+ let mut c = course();
+ c.lectures.get_mut("L1.1").expect("lecture").readings.pop();
+ let md = readings_markdown(&c, "L1.1", Style::Quarto).expect("renders");
+ assert!(!md.contains("Supplemental"));
+ }
+
+ #[test]
+ fn a_legacy_string_reading_still_renders() {
+ let mut c = course();
+ let readings = &mut c.lectures.get_mut("L1.1").expect("lecture").readings;
+ readings.clear();
+ readings.push(Reading {
+ text: Some("KKW §6.1: system and surroundings. https://example.org".into()),
+ ..Reading::default()
+ });
+ let md = readings_markdown(&c, "L1.1", Style::Quarto).expect("renders");
+ assert!(md.contains("system and surroundings"));
+ }
+
+ #[test]
+ fn an_unknown_reference_is_an_error_rather_than_a_blank() {
+ let mut c = course();
+ c.references.clear();
+ let err = readings_markdown(&c, "L1.1", Style::Quarto);
+ assert!(err.is_err());
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index c1ff233..4517392 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -110,7 +110,7 @@ pub use data::{canvas, gradescope, responses, store};
pub use analysis::{calibrate, classical, irt, students};
-pub use export::{qti, report, typst};
+pub use export::{lecture, qti, report, typst};
pub use catalog::Catalog;
pub use course::{CourseFile, SCHEMA_VERSION};
diff --git a/src/model/course.rs b/src/model/course.rs
index a648461..2dc2ef2 100644
--- a/src/model/course.rs
+++ b/src/model/course.rs
@@ -16,9 +16,12 @@
//! belongs to the course and the administration, never to the item.
use std::collections::BTreeMap;
+use std::fmt;
use std::path::Path;
-use serde::{Deserialize, Serialize};
+use serde::de::{self, MapAccess, Visitor};
+use serde::ser::SerializeMap;
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::date::Date;
use crate::error::{Error, Result};
@@ -61,6 +64,12 @@ pub struct CourseFile {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub learning_objectives: BTreeMap,
+ /// Works the course cites, keyed by citation key such as
+ /// `kuriyan2013molecules`. Readings point in here rather than restating a
+ /// citation, so a reference is written once and a changed edition is one edit.
+ #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
+ pub references: BTreeMap,
+
/// Shared stimuli for case-based testlets, keyed by id.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub stimuli: BTreeMap,
@@ -172,9 +181,318 @@ pub struct Lecture {
/// Where the slides live, for study guidance in student reports.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slides_url: Option,
- /// Assigned readings for the session.
+ /// Assigned readings for the session, in the order you assign them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
- pub readings: Vec,
+ pub readings: Vec,
+}
+
+/// A work the course cites: a textbook, an article, a dataset, a recording.
+///
+/// Keyed by citation key, so this registry is a bibliography rather than a second
+/// naming scheme. The field names follow BibTeX where BibTeX has one, which makes
+/// import and export mechanical.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Reference {
+ /// The short form a reading list shows, such as `KKW`. Unique across the
+ /// registry, because reports print it in place of the key.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub label: Option,
+ /// What kind of work this is, which decides how a citation renders.
+ #[serde(default)]
+ pub kind: ReferenceKind,
+ /// Whether the course requires it or lists it as background.
+ #[serde(default)]
+ pub role: ReferenceRole,
+ /// Full title.
+ pub title: String,
+ /// Authors as `Family, Given`, in the order printed on the work.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub authors: Vec,
+ /// Year of publication.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub year: Option,
+ /// Edition as printed: `7th`, `Revised`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub edition: Option,
+ /// Publisher, for a book.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub publisher: Option,
+ /// The journal, edited volume, or series this sits inside.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub container: Option,
+ /// Volume within the container.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub volume: Option,
+ /// Issue within the volume.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub issue: Option,
+ /// Page range of the work as a whole, not of any one reading.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pages: Option,
+ /// DOI, bare: `10.1038/nature12373`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub doi: Option,
+ /// ISBN, for a book.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub isbn: Option,
+ /// Canonical URL for the work as a whole.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub url: Option,
+ /// Prefix a reading's `path` is appended to. Having this means the citation
+ /// key appears once rather than once per reading.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub base_url: Option,
+ /// Anything students need to know about getting hold of it: reserve shelf,
+ /// license, paywall.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub note: Option,
+}
+
+/// The kind of work, chosen to map onto BibTeX entry types.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum ReferenceKind {
+ /// A whole book.
+ #[default]
+ Book,
+ /// A chapter in an edited volume.
+ Chapter,
+ /// A journal article.
+ Article,
+ /// A preprint, which is an article without a container.
+ Preprint,
+ /// A thesis or dissertation.
+ Thesis,
+ /// A page or resource that exists only online.
+ Website,
+ /// A program or library.
+ Software,
+ /// A published dataset.
+ Dataset,
+ /// A recording.
+ Video,
+ /// Anything else.
+ Other,
+}
+
+/// Whether the course requires a work or offers it as background.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum ReferenceRole {
+ /// A course text. Assigned readings come from it.
+ Required,
+ /// Listed so students know it exists. Never assigned.
+ #[default]
+ Supplemental,
+}
+
+/// One assigned location inside a [`Reference`], and what it is assigned for.
+///
+/// The prose splits three ways because each part answers a different question and
+/// each has a different consumer. `summary` says what the section contains, `focus`
+/// says what to take from it, and `skip` says what to ignore. A student report
+/// quotes `focus` at somebody who missed the objective; a lecture page prints all
+/// three.
+///
+/// A reading written as a bare string, which is what this field held before the
+/// schema existed, still parses: the whole string lands in `text`, and serializing
+/// writes it back out as a string rather than a mapping.
+#[derive(Debug, Clone, Default)]
+pub struct Reading {
+ /// Citation key into [`CourseFile::references`].
+ pub reference: Option,
+ /// Where inside the work: `§6.1`, `pp. 212-219`, `ch. 3`, `fig. 4`.
+ pub locator: Option,
+ /// Appended to the reference's `base_url` to reach this location.
+ pub path: Option,
+ /// A full URL, for a location that is not under the reference's `base_url`.
+ pub url: Option,
+ /// Whether it is assigned or offered alongside.
+ pub role: ReadingRole,
+ /// The objectives this reading serves.
+ pub objectives: Vec,
+ /// What the section contains.
+ pub summary: Option,
+ /// What to take from it, which is the sentence a study suggestion quotes.
+ pub focus: Option,
+ /// What to gloss, and why it is out of scope.
+ pub skip: Option,
+ /// A reading written as a bare string before this schema existed, held
+ /// unparsed.
+ pub text: Option,
+}
+
+/// Whether a reading is assigned or offered alongside.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum ReadingRole {
+ /// Assigned, and therefore fair to assess.
+ #[default]
+ Assigned,
+ /// Offered as background. Not separately assessed.
+ Supplemental,
+}
+
+impl Reading {
+ /// The URL for this location.
+ ///
+ /// # Arguments
+ ///
+ /// * `reference` - the work this reading is inside.
+ ///
+ /// # Returns
+ ///
+ /// `url` when given, otherwise the reference's `base_url` joined with `path`,
+ /// otherwise `None`.
+ pub fn resolve_url(&self, reference: &Reference) -> Option {
+ if let Some(url) = &self.url {
+ return Some(url.clone());
+ }
+ let path = self.path.as_deref()?;
+ let base = reference.base_url.as_deref()?;
+ Some(match (base.ends_with('/'), path.starts_with('/')) {
+ (true, true) => format!("{base}{}", &path[1..]),
+ (false, false) => format!("{base}/{path}"),
+ _ => format!("{base}{path}"),
+ })
+ }
+
+ /// A short citation for a report: `KKW §6.1`.
+ ///
+ /// # Arguments
+ ///
+ /// * `key` - the citation key, used when the reference declares no label.
+ /// * `reference` - the work, for its label.
+ ///
+ /// # Returns
+ ///
+ /// The label and locator, or the unparsed `text` for a legacy reading.
+ pub fn cite(&self, key: &str, reference: &Reference) -> String {
+ if let Some(text) = &self.text {
+ return text.clone();
+ }
+ let label = reference.label.as_deref().unwrap_or(key);
+ match &self.locator {
+ Some(locator) => format!("{label} {locator}"),
+ None => label.to_string(),
+ }
+ }
+}
+
+/// Writes a reading as a mapping, or as a bare string when that is all it holds.
+///
+/// The string case keeps a course file that predates this schema byte-identical
+/// through a load-and-save cycle, so migrating is something you choose rather than
+/// something the tool does to your file the first time it writes it.
+impl Serialize for Reading {
+ fn serialize(&self, s: S) -> std::result::Result {
+ if let Some(text) = &self.text {
+ if self.reference.is_none() && self.locator.is_none() && self.objectives.is_empty() {
+ return s.serialize_str(text);
+ }
+ }
+ let mut map = s.serialize_map(None)?;
+ if let Some(v) = &self.reference {
+ map.serialize_entry("ref", v)?;
+ }
+ if let Some(v) = &self.locator {
+ map.serialize_entry("locator", v)?;
+ }
+ if let Some(v) = &self.path {
+ map.serialize_entry("path", v)?;
+ }
+ if let Some(v) = &self.url {
+ map.serialize_entry("url", v)?;
+ }
+ if self.role != ReadingRole::Assigned {
+ map.serialize_entry("role", &self.role)?;
+ }
+ if !self.objectives.is_empty() {
+ map.serialize_entry("objectives", &self.objectives)?;
+ }
+ if let Some(v) = &self.summary {
+ map.serialize_entry("summary", v)?;
+ }
+ if let Some(v) = &self.focus {
+ map.serialize_entry("focus", v)?;
+ }
+ if let Some(v) = &self.skip {
+ map.serialize_entry("skip", v)?;
+ }
+ if let Some(v) = &self.text {
+ map.serialize_entry("text", v)?;
+ }
+ map.end()
+ }
+}
+
+/// Accepts a reading written either as a mapping or as a bare string.
+///
+/// The string form is what `readings` held before this schema, so course files
+/// written against the old shape keep loading. It is the same courtesy
+/// [`yaml::flexible_string`] extends to an unquoted `schema_version: 1.0`.
+impl<'de> Deserialize<'de> for Reading {
+ fn deserialize>(d: D) -> std::result::Result {
+ /// The mapping form, with the field set kept in one place.
+ #[derive(Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct Mapping {
+ #[serde(rename = "ref", default)]
+ reference: Option,
+ #[serde(default)]
+ locator: Option,
+ #[serde(default)]
+ path: Option,
+ #[serde(default)]
+ url: Option,
+ #[serde(default)]
+ role: ReadingRole,
+ #[serde(default)]
+ objectives: Vec,
+ #[serde(default)]
+ summary: Option,
+ #[serde(default)]
+ focus: Option,
+ #[serde(default)]
+ skip: Option,
+ #[serde(default)]
+ text: Option,
+ }
+
+ struct V;
+ impl<'a> Visitor<'a> for V {
+ type Value = Reading;
+
+ fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str("a reading mapping with a `ref`, or a plain citation string")
+ }
+
+ fn visit_str(self, v: &str) -> std::result::Result {
+ Ok(Reading {
+ text: Some(v.to_string()),
+ ..Reading::default()
+ })
+ }
+
+ fn visit_map>(self, map: M) -> std::result::Result {
+ let m = Mapping::deserialize(de::value::MapAccessDeserializer::new(map))?;
+ Ok(Reading {
+ reference: m.reference,
+ locator: m.locator,
+ path: m.path,
+ url: m.url,
+ role: m.role,
+ objectives: m.objectives,
+ summary: m.summary,
+ focus: m.focus,
+ skip: m.skip,
+ text: m.text,
+ })
+ }
+ }
+ d.deserialize_any(V)
+ }
}
/// A learning objective.
@@ -190,6 +508,15 @@ pub struct Objective {
/// The lectures that develop it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lectures: Vec,
+ /// Position in teaching order, low first.
+ ///
+ /// The registry is a map, so declaration order is lost on load, and sorting by
+ /// id would put `lo-enthalpy` before `lo-first-law` when the second is a
+ /// prerequisite of the first. Anything that prints objectives in the order you
+ /// teach them, a lecture page above all, needs this. Objectives without it sort
+ /// last, by id.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub order: Option,
/// The highest level you intend to assess this objective at. Assembling an
/// item above the ceiling is a warning: either the item overreaches or the
/// ceiling needs raising.
@@ -306,6 +633,25 @@ impl CourseFile {
}
}
+ let mut labels: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
+ for (key, reference) in &self.references {
+ if reference.title.trim().is_empty() {
+ issues.push(format!("reference `{key}`: empty title"));
+ }
+ if let Some(label) = &reference.label {
+ labels.entry(label.as_str()).or_default().push(key);
+ }
+ }
+ for (label, keys) in &labels {
+ if keys.len() > 1 {
+ issues.push(format!(
+ "references: `{label}` is the label of {}; a label has to name one work \
+ because reports print it instead of the key",
+ keys.join(" and ")
+ ));
+ }
+ }
+
for (id, lec) in &self.lectures {
if lec.title.trim().is_empty() {
issues.push(format!("lecture `{id}`: empty title"));
@@ -315,6 +661,10 @@ impl CourseFile {
issues.push(format!("lecture `{id}`: unknown unit `{u}`"));
}
}
+ let mut seen: Vec<(&str, &str)> = Vec::new();
+ for (index, reading) in lec.readings.iter().enumerate() {
+ issues.extend(self.reading_issues(id, index, reading, &mut seen));
+ }
}
for (id, lo) in &self.learning_objectives {
@@ -347,6 +697,66 @@ impl CourseFile {
issues
}
+ /// Checks one reading, collecting every problem with it.
+ ///
+ /// # Arguments
+ ///
+ /// * `lecture` - the lecture id, for the message.
+ /// * `index` - position in the lecture's list, since a reading has no id.
+ /// * `reading` - the reading.
+ /// * `seen` - reference and locator pairs already found in this lecture,
+ /// extended as it goes.
+ ///
+ /// # Returns
+ ///
+ /// One message per problem.
+ fn reading_issues<'a>(
+ &self,
+ lecture: &str,
+ index: usize,
+ reading: &'a Reading,
+ seen: &mut Vec<(&'a str, &'a str)>,
+ ) -> Vec {
+ let mut issues = Vec::new();
+ let at = format!("lecture `{lecture}` reading {}", index + 1);
+
+ let Some(key) = reading.reference.as_deref() else {
+ if reading.text.is_none() {
+ issues.push(format!(
+ "{at}: needs a `ref` naming a reference, or a plain citation string"
+ ));
+ }
+ return issues;
+ };
+
+ match self.references.get(key) {
+ None => issues.push(format!("{at}: unknown reference `{key}`")),
+ Some(reference) => {
+ if reading.path.is_some() && reference.base_url.is_none() && reading.url.is_none() {
+ issues.push(format!(
+ "{at}: has a `path` but reference `{key}` has no `base_url` to join it to"
+ ));
+ }
+ }
+ }
+
+ if let Some(locator) = reading.locator.as_deref() {
+ if seen.contains(&(key, locator)) {
+ issues.push(format!(
+ "{at}: `{key} {locator}` is assigned twice in one lecture"
+ ));
+ }
+ seen.push((key, locator));
+ }
+
+ for objective in &reading.objectives {
+ if !self.learning_objectives.contains_key(objective) {
+ issues.push(format!("{at}: unknown learning objective `{objective}`"));
+ }
+ }
+ issues
+ }
+
/// Detects cycles in the objective prerequisite graph.
///
/// A cycle would make a study-order suggestion loop forever, so it is worth
@@ -470,7 +880,8 @@ impl CourseFile {
.unwrap_or_else(|| id.to_string())
}
- /// Objectives in a stable teaching order: by unit as declared, then by id.
+ /// Objectives in a stable teaching order: by unit as declared, then by
+ /// [`Objective::order`], then by id.
///
/// # Returns
///
@@ -490,11 +901,148 @@ impl CourseFile {
.as_deref()
.and_then(|u| unit_rank.get(u).copied())
.unwrap_or(usize::MAX);
- (rank, (*id).clone())
+ (rank, lo.order.unwrap_or(u32::MAX), (*id).clone())
});
ids.into_iter().cloned().collect()
}
+ /// The objectives a lecture covers, in teaching order.
+ ///
+ /// # Arguments
+ ///
+ /// * `lecture` - the lecture id.
+ ///
+ /// # Returns
+ ///
+ /// Objective ids whose `lectures` list names this lecture, ordered by
+ /// [`Objective::order`] and then by id.
+ pub fn lecture_objectives(&self, lecture: &str) -> Vec<&str> {
+ let mut ids: Vec<&String> = self
+ .learning_objectives
+ .iter()
+ .filter(|(_, lo)| lo.lectures.iter().any(|l| l == lecture))
+ .map(|(id, _)| id)
+ .collect();
+ ids.sort_by_key(|id| {
+ let lo = &self.learning_objectives[*id];
+ (lo.order.unwrap_or(u32::MAX), (*id).clone())
+ });
+ ids.into_iter().map(String::as_str).collect()
+ }
+
+ /// Every reading that serves an objective, with the lecture it was assigned in.
+ ///
+ /// Derived by scanning lectures rather than stored on the objective, for the
+ /// same reason [`crate::history::History`] derives usage from assessment
+ /// records: a second copy of an edge is a second thing to keep in step. It also
+ /// puts the pointer on the volatile side, since a new edition renumbers
+ /// sections but leaves your objectives alone.
+ ///
+ /// # Arguments
+ ///
+ /// * `objective` - the objective id.
+ ///
+ /// # Returns
+ ///
+ /// Pairs of lecture id and reading, in lecture order then assignment order.
+ pub fn readings_for_objective(&self, objective: &str) -> Vec<(&str, &Reading)> {
+ let mut out = Vec::new();
+ for (lecture_id, lecture) in &self.lectures {
+ for reading in &lecture.readings {
+ if reading.objectives.iter().any(|o| o == objective) {
+ out.push((lecture_id.as_str(), reading));
+ }
+ }
+ }
+ out
+ }
+
+ /// Assessed objectives with no reading behind them.
+ ///
+ /// These are the objectives a student report cannot advise on: it can say the
+ /// objective was missed, but not where to go and read about it.
+ ///
+ /// # Returns
+ ///
+ /// Objective ids in teaching order.
+ pub fn objectives_without_readings(&self) -> Vec<&str> {
+ let cited: std::collections::BTreeSet<&str> = self
+ .lectures
+ .values()
+ .flat_map(|l| l.readings.iter())
+ .flat_map(|r| r.objectives.iter())
+ .map(String::as_str)
+ .collect();
+ self.objectives_in_order()
+ .into_iter()
+ .filter_map(|id| {
+ let (key, lo) = self.learning_objectives.get_key_value(&id)?;
+ (lo.assessed && !cited.contains(key.as_str())).then_some(key.as_str())
+ })
+ .collect()
+ }
+
+ /// Looks up a reference, erroring on a dangling citation key.
+ ///
+ /// # Arguments
+ ///
+ /// * `key` - the citation key.
+ /// * `context` - what cited it, for the error message.
+ ///
+ /// # Returns
+ ///
+ /// The reference.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Unresolved`] when the key is not registered.
+ pub fn reference(&self, key: &str, context: &str) -> Result<&Reference> {
+ self.references.get(key).ok_or_else(|| Error::Unresolved {
+ kind: "reference",
+ id: key.to_string(),
+ context: Some(context.to_string()),
+ })
+ }
+
+ /// Expands `{objective-id}` in a prose field to whatever the caller wants.
+ ///
+ /// Reading notes refer to objectives in passing ("a worked instance of
+ /// `{lo-vdw-additivity}`"), and a lecture page renders that as a number while a
+ /// student report renders it as text. Only a name that resolves to a declared
+ /// objective is treated as a placeholder, so `$U_\text{final}$` passes through
+ /// untouched; that collision is the reason this is not a general template
+ /// syntax.
+ ///
+ /// # Arguments
+ ///
+ /// * `prose` - the field to expand.
+ /// * `render` - called with each resolved objective id.
+ ///
+ /// # Returns
+ ///
+ /// The prose with resolved placeholders replaced.
+ pub fn expand_objective_refs(&self, prose: &str, render: impl Fn(&str) -> String) -> String {
+ let mut out = String::with_capacity(prose.len());
+ let mut rest = prose;
+ while let Some(open) = rest.find('{') {
+ let (head, tail) = rest.split_at(open);
+ out.push_str(head);
+ let Some(close) = tail.find('}') else {
+ out.push_str(tail);
+ return out;
+ };
+ let name = &tail[1..close];
+ if self.learning_objectives.contains_key(name) {
+ out.push_str(&render(name));
+ } else {
+ out.push_str(&tail[..=close]);
+ }
+ rest = &tail[close + 1..];
+ }
+ out.push_str(rest);
+ out
+ }
+
/// A skeleton course file for `coursebank init`.
///
/// # Arguments
@@ -525,6 +1073,7 @@ impl CourseFile {
text: "Replace this with an objective stated as a student action.".to_string(),
unit: Some("u-intro".to_string()),
lectures: vec!["L01".to_string()],
+ order: Some(1),
level_ceiling: Some(Level::Understand),
prerequisites: Vec::new(),
tags: Vec::new(),
@@ -549,6 +1098,7 @@ impl CourseFile {
}],
lectures,
learning_objectives: los,
+ references: BTreeMap::new(),
stimuli: BTreeMap::new(),
}
}
@@ -706,4 +1256,215 @@ learning_objectives:
assert_eq!(slugify("Exam 4 -- Final!"), "exam-4-final");
assert_eq!(slugify(" "), "");
}
+
+ /// A course with one reference and two readings, one of them supplemental.
+ fn with_readings() -> CourseFile {
+ parse(
+ r#"
+course: { code: X, title: Y, term: Z }
+references:
+ kuriyan2013molecules:
+ label: KKW
+ role: required
+ title: The molecules of life
+ base_url: https://example.org/kkw/
+lectures:
+ L1.1:
+ title: Enthalpy
+ readings:
+ - ref: kuriyan2013molecules
+ locator: '§6.1'
+ path: '6/A/#1'
+ objectives: [lo-a]
+ summary: What a system is.
+ focus: Fix the definitions.
+ - ref: kuriyan2013molecules
+ locator: '§1.9'
+ path: '1/B/#9'
+ role: supplemental
+ objectives: [lo-b]
+learning_objectives:
+ lo-a: { text: A, lectures: [L1.1], order: 1 }
+ lo-b: { text: B, lectures: [L1.1], order: 2 }
+"#,
+ )
+ }
+
+ #[test]
+ fn a_structured_reading_parses_and_validates() {
+ let c = with_readings();
+ assert!(c.validate().is_empty(), "{:?}", c.validate());
+ let readings = &c.lectures["L1.1"].readings;
+ assert_eq!(
+ readings[0].reference.as_deref(),
+ Some("kuriyan2013molecules")
+ );
+ assert_eq!(readings[0].role, ReadingRole::Assigned);
+ assert_eq!(readings[1].role, ReadingRole::Supplemental);
+ }
+
+ #[test]
+ fn a_reading_url_is_built_from_the_reference_base() {
+ let c = with_readings();
+ let reference = &c.references["kuriyan2013molecules"];
+ let reading = &c.lectures["L1.1"].readings[0];
+ assert_eq!(
+ reading.resolve_url(reference).as_deref(),
+ Some("https://example.org/kkw/6/A/#1")
+ );
+ assert_eq!(reading.cite("kuriyan2013molecules", reference), "KKW §6.1");
+ }
+
+ #[test]
+ fn a_bare_string_reading_still_parses_and_round_trips() {
+ let c = parse(
+ r#"
+course: { code: X, title: Y, term: Z }
+lectures:
+ L01:
+ title: One
+ readings:
+ - 'KKW §6.1: system and surroundings. https://example.org/1'
+"#,
+ );
+ let reading = &c.lectures["L01"].readings[0];
+ assert!(reading.reference.is_none());
+ assert_eq!(
+ reading.text.as_deref(),
+ Some("KKW §6.1: system and surroundings. https://example.org/1")
+ );
+ assert!(c.validate().is_empty());
+
+ // Serializing writes the string back as a string, so a load-and-save cycle
+ // does not migrate a file the author has not chosen to migrate.
+ let yaml = serde_yaml_ng::to_string(&c).expect("serializes");
+ assert!(yaml.contains("- 'KKW §6.1: system and surroundings. https://example.org/1'"));
+ assert!(!yaml.contains("text:"));
+ }
+
+ #[test]
+ fn readings_resolve_backwards_from_an_objective() {
+ let c = with_readings();
+ let found = c.readings_for_objective("lo-a");
+ assert_eq!(found.len(), 1);
+ assert_eq!(found[0].0, "L1.1");
+ assert_eq!(found[0].1.locator.as_deref(), Some("§6.1"));
+ assert!(c.readings_for_objective("lo-nobody").is_empty());
+ }
+
+ #[test]
+ fn an_objective_with_no_reading_is_reported() {
+ let mut c = with_readings();
+ assert!(c.objectives_without_readings().is_empty());
+ c.learning_objectives.insert(
+ "lo-orphan".to_string(),
+ Objective {
+ text: "Orphan".to_string(),
+ unit: None,
+ lectures: vec!["L1.1".to_string()],
+ order: Some(3),
+ level_ceiling: None,
+ prerequisites: Vec::new(),
+ tags: Vec::new(),
+ assessed: true,
+ },
+ );
+ assert_eq!(c.objectives_without_readings(), vec!["lo-orphan"]);
+
+ // An objective you teach but do not test is not a gap.
+ c.learning_objectives
+ .get_mut("lo-orphan")
+ .expect("just inserted")
+ .assessed = false;
+ assert!(c.objectives_without_readings().is_empty());
+ }
+
+ #[test]
+ fn objective_order_beats_id_order_within_a_lecture() {
+ let c = parse(
+ r#"
+course: { code: X, title: Y, term: Z }
+lectures:
+ L1.1: { title: One }
+learning_objectives:
+ lo-enthalpy: { text: Third, lectures: [L1.1], order: 3 }
+ lo-first-law: { text: Second, lectures: [L1.1], order: 2 }
+ lo-system: { text: First, lectures: [L1.1], order: 1 }
+"#,
+ );
+ // Alphabetically this is enthalpy, first-law, system, which puts an
+ // objective ahead of its own prerequisite.
+ assert_eq!(
+ c.lecture_objectives("L1.1"),
+ vec!["lo-system", "lo-first-law", "lo-enthalpy"]
+ );
+ }
+
+ #[test]
+ fn unknown_references_and_objectives_on_a_reading_are_reported() {
+ let c = parse(
+ r#"
+course: { code: X, title: Y, term: Z }
+references:
+ known: { title: A book }
+lectures:
+ L01:
+ title: One
+ readings:
+ - { ref: missing, locator: '§1' }
+ - { ref: known, locator: '§2', path: '2/', objectives: [lo-nope] }
+"#,
+ );
+ let issues = c.validate();
+ assert!(
+ issues
+ .iter()
+ .any(|i| i.contains("unknown reference `missing`"))
+ );
+ assert!(
+ issues
+ .iter()
+ .any(|i| i.contains("unknown learning objective `lo-nope`"))
+ );
+ // `path` with no base_url to join it to.
+ assert!(issues.iter().any(|i| i.contains("base_url")));
+ }
+
+ #[test]
+ fn a_duplicate_label_and_a_duplicate_locator_are_reported() {
+ let c = parse(
+ r#"
+course: { code: X, title: Y, term: Z }
+references:
+ one: { title: First, label: KKW }
+ two: { title: Second, label: KKW }
+lectures:
+ L01:
+ title: One
+ readings:
+ - { ref: one, locator: '§1' }
+ - { ref: one, locator: '§1' }
+"#,
+ );
+ let issues = c.validate();
+ assert!(issues.iter().any(|i| i.contains("`KKW` is the label of")));
+ assert!(
+ issues
+ .iter()
+ .any(|i| i.contains("assigned twice in one lecture"))
+ );
+ }
+
+ #[test]
+ fn only_a_declared_objective_id_is_a_placeholder() {
+ let c = with_readings();
+ let expanded = c.expand_objective_refs(
+ r"a worked instance of {lo-a}, where $U_\text{final}$ is unchanged, {lo-typo} too",
+ |id| format!("<{id}>"),
+ );
+ assert_eq!(
+ expanded,
+ r"a worked instance of , where $U_\text{final}$ is unchanged, {lo-typo} too"
+ );
+ }
}
--
2.54.0
From 327ac371e48fce103ab951fb08adf42cac0a0ca3 Mon Sep 17 00:00:00 2001
From: Alex Maldonado
Date: Mon, 10 Aug 2026 01:52:15 -0400
Subject: [PATCH 03/26] feat: improve worksheet
---
.gitignore | 1 +
src/analysis/irt.rs | 8 +-
src/authoring/jsonschema.rs | 82 ++++-
src/authoring/lint.rs | 14 +-
src/authoring/select.rs | 8 +-
src/cli.rs | 23 ++
src/commands/export.rs | 57 +++
src/export.rs | 2 +
src/export/practice.rs | 669 ++++++++++++++++++++++++++++++++++++
src/export/qti.rs | 111 +++++-
src/export/report.rs | 22 +-
src/export/typst/payload.rs | 6 +-
src/lib.rs | 2 +-
src/model/bank.rs | 107 +++++-
src/model/item.rs | 313 ++++++++++++++++-
src/model/taxonomy.rs | 50 +++
src/util/markup.rs | 43 +++
17 files changed, 1469 insertions(+), 49 deletions(-)
create mode 100644 src/export/practice.rs
diff --git a/.gitignore b/.gitignore
index d650d90..5a6fc24 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
preview
+scratch
/dist/
/THIRD-PARTY-LICENSES.txt
diff --git a/src/analysis/irt.rs b/src/analysis/irt.rs
index 5a30bda..872b79d 100644
--- a/src/analysis/irt.rs
+++ b/src/analysis/irt.rs
@@ -444,7 +444,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
for iteration in 0..opts.max_iterations {
iterations = iteration + 1;
- // ---- E step: expected counts at each quadrature point ----
+ // --- E step: expected counts at each quadrature point ----
// Counts are accumulated per item rather than globally, so an item
// administered to only some examinees is not charged for the others.
let mut n_kj = vec![vec![0.0f64; j_count]; n_quad];
@@ -473,7 +473,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
}
}
- // ---- M step: one two-parameter Newton solve per item ----
+ // --- M step: one two-parameter Newton solve per item ----
let mut delta = 0.0f64;
for j in 0..j_count {
let counts: Vec<(f64, f64)> = (0..n_quad).map(|k| (n_kj[k][j], r_k[k][j])).collect();
@@ -529,7 +529,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
));
}
- // ---- Standard errors and per-item notes ----
+ // --- Standard errors and per-item notes ----
let (grid_final, weight_final) = (grid.clone(), base_weight.clone());
let mut p_grid = vec![vec![0.0f64; j_count]; n_quad];
for k in 0..n_quad {
@@ -601,7 +601,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
});
}
- // ---- Abilities, expected a posteriori ----
+ // --- Abilities, expected a posteriori ----
let mut abilities = Vec::with_capacity(n);
let mut log_likelihood = 0.0f64;
for i in 0..n {
diff --git a/src/authoring/jsonschema.rs b/src/authoring/jsonschema.rs
index 8d4e030..3d9ce29 100644
--- a/src/authoring/jsonschema.rs
+++ b/src/authoring/jsonschema.rs
@@ -571,6 +571,77 @@ fn asset_schema() -> Value {
})
}
+/// 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!({
@@ -738,9 +809,10 @@ fn item_identity_properties() -> Value {
"cognitive_process": cognitive_process(),
"format": {
"type": "string",
- "enum": ["single_best_answer", "multiple_response", "true_false"],
- "description": "single_best_answer requires exactly one keyed option; \
- multiple_response requires at least two."
+ "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 },
@@ -767,8 +839,10 @@ fn item_content_properties() -> Value {
"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."
@@ -807,7 +881,7 @@ fn item_schema() -> Value {
}
json!({
"type": "object",
- "required": ["id", "level", "stem", "options"],
+ "required": ["id", "level", "stem"],
"additionalProperties": false,
"properties": Value::Object(properties)
})
diff --git a/src/authoring/lint.rs b/src/authoring/lint.rs
index 040c613..6b78b45 100644
--- a/src/authoring/lint.rs
+++ b/src/authoring/lint.rs
@@ -421,7 +421,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec = stem.split_whitespace().collect();
@@ -513,7 +513,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec = it.options.iter().filter(|o| o.correct).collect();
let distractors: Vec<&crate::item::Choice> = it.options.iter().filter(|o| !o.correct).collect();
@@ -659,7 +659,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec Vec Vec {
if e.item.status == Status::Retired {
continue;
}
+ // An open-response item carries no options, so it is neither part of the
+ // count norm nor able to deviate from it. Leaving it out keeps a bank of
+ // four-option questions from reporting every essay as an odd count.
+ if !e.item.has_options() {
+ continue;
+ }
by_bank.entry(e.bank.as_str()).or_default().push(e);
}
diff --git a/src/authoring/select.rs b/src/authoring/select.rs
index 09fbff9..2614f98 100644
--- a/src/authoring/select.rs
+++ b/src/authoring/select.rs
@@ -84,7 +84,7 @@ pub fn select(
let seed = blueprint.seed.unwrap_or(0);
let mut notes = Vec::new();
- // ------------------------------------------------------------------ pool
+ // --- pool
let eligible: Vec<&crate::catalog::Entry> = catalog
.assemblable()
.into_iter()
@@ -101,7 +101,7 @@ pub fn select(
let mut chosen: Vec = Vec::new();
let mut per_bank: BTreeMap = BTreeMap::new();
- // ---------------------------------------------------- objective minimums
+ // --- objective minimums
// Placed first, because a coverage requirement is the constraint most likely
// to become unsatisfiable once the level quotas are full.
for (objective, needed) in &blueprint.objective_minimums {
@@ -140,7 +140,7 @@ pub fn select(
}
}
- // ------------------------------------------------------- level quotas
+ // --- level quotas
let mut scored: Vec = Vec::new();
for (level, want) in &blueprint.level_counts {
if *want == 0 {
@@ -175,7 +175,7 @@ pub fn select(
}
}
- // ------------------------------------------------------------ bonus items
+ // --- bonus items
let mut bonus: Vec = Vec::new();
for (level, want) in &blueprint.bonus_counts {
if *want == 0 {
diff --git a/src/cli.rs b/src/cli.rs
index ac7a66d..9a6a9ab 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -412,6 +412,29 @@ pub(crate) enum ExportCommand {
#[arg(long)]
out: Option,
},
+ /// Write a Quarto worksheet and a matching solutions document.
+ ///
+ /// The worksheet holds the questions and nothing else; the solutions document
+ /// adds the key, the worked reasoning, the rubric, and the readings to revisit.
+ /// This is the path that does not go through Canvas, so a student can practice
+ /// from the `.qmd` and check themselves against the solutions. Render each with
+ /// `quarto render .qmd`.
+ Practice {
+ /// Assessment id.
+ id: String,
+ /// Which form's ordering to use.
+ #[arg(long, default_value = "A")]
+ form: String,
+ /// Which documents to write; defaults to both. Values: worksheet, solutions.
+ #[arg(long, value_name = "DOC")]
+ variant: Vec,
+ /// Output directory; defaults to build/.
+ #[arg(long)]
+ out: Option,
+ /// Do not leave written-answer space after open-response questions.
+ #[arg(long)]
+ no_answer_space: bool,
+ },
}
#[derive(Debug, Subcommand)]
diff --git a/src/commands/export.rs b/src/commands/export.rs
index ef645b3..ed6eb69 100644
--- a/src/commands/export.rs
+++ b/src/commands/export.rs
@@ -12,6 +12,7 @@
use coursebank::assessment::Form;
use coursebank::error::{Error, Result};
use coursebank::layout::Layout;
+use coursebank::practice;
use coursebank::qti;
use coursebank::typst;
use coursebank::yaml;
@@ -168,9 +169,65 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result {
println!("wrote {}", path.display());
Ok(Outcome::Ok)
}
+ ExportCommand::Practice {
+ id,
+ form,
+ variant,
+ out,
+ no_answer_space,
+ } => {
+ let record = load_record(&catalog, id)?;
+ let form = pick_form(&record, form)?;
+ let dir = out.clone().unwrap_or(build);
+ for v in pick_practice_variants(variant)? {
+ let opts = practice::Options {
+ form: form.clone(),
+ variant: v,
+ answer_space: !no_answer_space,
+ };
+ let text = practice::render(&catalog, &record, &opts)?;
+ let path = dir.join(format!("{id}-{}{}.qmd", form.id, v.suffix()));
+ yaml::write_text(&path, &text)?;
+ println!("wrote {}", path.display());
+ }
+ if !cli.quiet {
+ println!("\nRender with: quarto render .qmd");
+ }
+ Ok(Outcome::Ok)
+ }
}
}
+/// Resolves the `--variant` flags for `export practice`, defaulting to both.
+///
+/// # Arguments
+///
+/// * `names` - the raw flag values, possibly empty.
+///
+/// # Returns
+///
+/// The documents to write, deduplicated and in canonical order (worksheet first).
+///
+/// # Errors
+///
+/// Returns [`Error::Usage`] naming the valid tokens.
+fn pick_practice_variants(names: &[String]) -> Result> {
+ if names.is_empty() {
+ return Ok(practice::Variant::ALL.to_vec());
+ }
+ let mut wanted = Vec::new();
+ for name in names {
+ let variant = practice::Variant::parse(name)?;
+ if !wanted.contains(&variant) {
+ wanted.push(variant);
+ }
+ }
+ Ok(practice::Variant::ALL
+ .into_iter()
+ .filter(|v| wanted.contains(v))
+ .collect())
+}
+
/// Resolves the `--variant` flags, defaulting to every document.
///
/// # Arguments
diff --git a/src/export.rs b/src/export.rs
index 6de4840..c799049 100644
--- a/src/export.rs
+++ b/src/export.rs
@@ -8,6 +8,7 @@
//! |:--|:--|:--|
//! | [`qti`] | a QTI 1.2 zip | importing into Canvas |
//! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet |
+//! | [`practice`] | Quarto Markdown | a worksheet and a solutions document, off Canvas |
//! | [`report`] | Markdown and HTML | students, and yourself |
//! | [`lecture`] | Markdown | the reading list on the course website |
//!
@@ -22,6 +23,7 @@
//! The instructor report answers "what should I fix?" and holds the item statistics.
pub mod lecture;
+pub mod practice;
pub mod qti;
pub mod report;
pub mod typst;
diff --git a/src/export/practice.rs b/src/export/practice.rs
new file mode 100644
index 0000000..b49f288
--- /dev/null
+++ b/src/export/practice.rs
@@ -0,0 +1,669 @@
+// SPDX-License-Identifier: Prosperity-3.0.0
+// Copyright Scientific Computing Studio
+// Source: https://git.scient.ing/education/coursebank
+
+//! Rendering an assessment as a Quarto worksheet a student can work through, and
+//! a matching solutions document they can learn from.
+//!
+//! This is the path that does not go through Canvas. You assemble a homework,
+//! quiz, or practice set the same way you assemble an exam, then render it as two
+//! `.qmd` files: [`Variant::Worksheet`] holds the questions and nothing else, and
+//! [`Variant::Solutions`] holds the same questions with the key marked, the worked
+//! reasoning, the rubric for anything open-ended, and where to read again. A
+//! student with neither the Canvas quiz nor the printed exam can still practice
+//! from the worksheet and check themselves against the solutions.
+//!
+//! A worksheet never contains the answer. It is built only from stems and
+//! options, and the option letters are the printed positions, so the document has
+//! nothing in it to leak: not a `correct` flag, not a solution, not a rationale.
+//! [`Variant::Solutions`] is a separate render from the same input.
+//!
+//! Option order comes from the form's seed. When a form shuffles, both
+//! documents relabel to the printed order through
+//! [`select::option_order`], so a worksheet handed to
+//! a student who saw form B agrees with the form B solutions.
+//!
+//! Everything a solution shows is authored: the model answer, the explanation, the
+//! per-option notes, the rubric, and the review citations. Nothing is invented
+//! here. A question with an empty [`crate::item::Solution`] renders its key and
+//! stops, which is a visible cue to go finish writing it.
+
+use crate::assessment::{AssessmentFile, Form, Placement};
+use crate::catalog::Catalog;
+use crate::course::{CourseFile, Reference};
+use crate::error::Result;
+use crate::item::{Choice, Citation, Item};
+use crate::markup;
+use crate::select;
+
+/// Which of the two documents to render.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum Variant {
+ /// Questions only, for a student to work through.
+ #[default]
+ Worksheet,
+ /// Questions with the key, worked solutions, rubric, and readings.
+ Solutions,
+}
+
+impl Variant {
+ /// Both documents, in the order they are usually written.
+ pub const ALL: [Variant; 2] = [Variant::Worksheet, Variant::Solutions];
+
+ /// The token used on the command line and in a file name.
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Variant::Worksheet => "worksheet",
+ Variant::Solutions => "solutions",
+ }
+ }
+
+ /// The suffix a generated file name carries, e.g. `-solutions`.
+ pub fn suffix(self) -> &'static str {
+ match self {
+ Variant::Worksheet => "",
+ Variant::Solutions => "-solutions",
+ }
+ }
+
+ /// The word for this document in a title.
+ fn title_word(self) -> &'static str {
+ match self {
+ Variant::Worksheet => "Questions",
+ Variant::Solutions => "Solutions",
+ }
+ }
+
+ /// Parses a `--variant` value.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - the token, case insensitive; `questions` is accepted for the
+ /// worksheet and `key` for the solutions, since those are what people type.
+ ///
+ /// # Returns
+ ///
+ /// The variant.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`crate::error::Error::Usage`] naming the valid tokens.
+ pub fn parse(name: &str) -> Result {
+ match name.trim().to_ascii_lowercase().as_str() {
+ "worksheet" | "questions" | "q" => Ok(Variant::Worksheet),
+ "solutions" | "solution" | "key" => Ok(Variant::Solutions),
+ other => Err(crate::error::Error::usage(format!(
+ "unknown practice document `{other}`; use worksheet or solutions"
+ ))),
+ }
+ }
+}
+
+/// What to render.
+#[derive(Debug, Clone)]
+pub struct Options {
+ /// Which form's ordering to use. Defaults to an unshuffled form.
+ pub form: Form,
+ /// Which document.
+ pub variant: Variant,
+ /// Leave vertical space after each question on the worksheet for a written
+ /// answer. Ignored for the solutions document.
+ pub answer_space: bool,
+}
+
+impl Default for Options {
+ fn default() -> Options {
+ Options {
+ form: Form {
+ id: "A".to_string(),
+ seed: 0,
+ shuffle_items: false,
+ shuffle_options: false,
+ },
+ variant: Variant::Worksheet,
+ answer_space: true,
+ }
+ }
+}
+
+impl Options {
+ /// Options for one variant on one form.
+ ///
+ /// # Arguments
+ ///
+ /// * `variant` - which document.
+ /// * `form` - the form whose ordering to use.
+ ///
+ /// # Returns
+ ///
+ /// The options, with the answer space on.
+ pub fn new(variant: Variant, form: Form) -> Options {
+ Options {
+ form,
+ variant,
+ answer_space: true,
+ }
+ }
+}
+
+/// Renders the questions-only worksheet.
+///
+/// # Arguments
+///
+/// * `catalog` - the loaded course.
+/// * `record` - the assessment record.
+/// * `form` - the form whose ordering to use.
+///
+/// # Returns
+///
+/// The Quarto Markdown, ending in a newline.
+///
+/// # Errors
+///
+/// Returns [`crate::error::Error::Unresolved`] when a placement references a
+/// missing item.
+pub fn worksheet(catalog: &Catalog, record: &AssessmentFile, form: &Form) -> Result {
+ render(
+ catalog,
+ record,
+ &Options::new(Variant::Worksheet, form.clone()),
+ )
+}
+
+/// Renders the solutions document.
+///
+/// # Arguments
+///
+/// * `catalog` - the loaded course.
+/// * `record` - the assessment record.
+/// * `form` - the form whose ordering to use.
+///
+/// # Returns
+///
+/// The Quarto Markdown, ending in a newline.
+///
+/// # Errors
+///
+/// As [`worksheet`].
+pub fn solutions(catalog: &Catalog, record: &AssessmentFile, form: &Form) -> Result {
+ render(
+ catalog,
+ record,
+ &Options::new(Variant::Solutions, form.clone()),
+ )
+}
+
+/// Renders one document.
+///
+/// # Arguments
+///
+/// * `catalog` - the loaded course.
+/// * `record` - the assessment record.
+/// * `opts` - what to render.
+///
+/// # Returns
+///
+/// The Quarto Markdown, ending in a newline.
+///
+/// # Errors
+///
+/// Returns [`crate::error::Error::Unresolved`] when a placement references a
+/// missing item.
+pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result {
+ let course = &catalog.course;
+ let mut out = front_matter(course, record, opts.variant);
+
+ if let Some(instructions) = &record.assessment.instructions {
+ out.push_str(&markup::to_markdown(instructions));
+ out.push_str("\n\n");
+ }
+
+ // One shared stimulus is printed once, above the first question that uses it,
+ // so a testlet reads as a block rather than repeating the vignette per item.
+ let mut printed_stimulus: Option = None;
+
+ let layout = select::layout(record, &opts.form);
+ for (position, placement) in layout.iter().filter(|p| !p.dropped).enumerate() {
+ let entry = catalog.require(&placement.item)?;
+ let item = &entry.item;
+ let number = position + 1;
+
+ if let Some(stimulus_id) = &item.stimulus {
+ if printed_stimulus.as_deref() != Some(stimulus_id.as_str()) {
+ if let Some(stimulus) = course.stimuli.get(stimulus_id) {
+ out.push_str("::: {.stimulus}\n\n");
+ out.push_str(&markup::to_markdown(&stimulus.body));
+ out.push_str("\n\n:::\n\n");
+ }
+ printed_stimulus = Some(stimulus_id.clone());
+ }
+ }
+
+ match opts.variant {
+ Variant::Worksheet => worksheet_question(
+ &mut out,
+ number,
+ placement,
+ item,
+ &opts.form,
+ opts.answer_space,
+ ),
+ Variant::Solutions => {
+ solution_question(&mut out, number, placement, item, &opts.form, course)
+ }
+ }
+ }
+
+ Ok(out)
+}
+
+/// The Quarto YAML front matter.
+fn front_matter(course: &CourseFile, record: &AssessmentFile, variant: Variant) -> String {
+ let title = format!("{}: {}", record.assessment.title, variant.title_word());
+ let subtitle = format!("{} · {}", course.course.code, course.course.title);
+ let mut out = String::from("---\n");
+ out.push_str(&format!("title: \"{}\"\n", yaml_quote(&title)));
+ out.push_str(&format!("subtitle: \"{}\"\n", yaml_quote(&subtitle)));
+ if let Some(date) = record.assessment.date {
+ out.push_str(&format!("date: \"{date}\"\n"));
+ }
+ out.push_str("format:\n html:\n toc: false\n number-sections: false\n");
+ out.push_str("---\n\n");
+ out
+}
+
+/// One question on the worksheet: stem, options in printed order, no answer.
+fn worksheet_question(
+ out: &mut String,
+ number: usize,
+ placement: &Placement,
+ item: &Item,
+ form: &Form,
+ answer_space: bool,
+) {
+ out.push_str(&heading(number, placement));
+ out.push_str(&markup::to_markdown(&item.stem));
+ out.push_str("\n\n");
+
+ if item.has_options() {
+ let ordered = ordered_options(item, form, &placement.item);
+ for (position, source) in ordered.iter().enumerate() {
+ out.push_str(&format!(
+ "{}. {}\n",
+ letter(position),
+ markup::to_markdown(&source.text)
+ ));
+ }
+ out.push('\n');
+ } else if answer_space {
+ // A place to write, sized by the theme, present only when asked for.
+ out.push_str("::: {.answer-space}\n:::\n\n");
+ }
+}
+
+/// One question in the solutions document: stem, key, worked reasoning, rubric,
+/// and where to look again.
+fn solution_question(
+ out: &mut String,
+ number: usize,
+ placement: &Placement,
+ item: &Item,
+ form: &Form,
+ course: &CourseFile,
+) {
+ out.push_str(&heading(number, placement));
+ out.push_str(&meta_line(placement, item));
+ out.push_str(&markup::to_markdown(&item.stem));
+ out.push_str("\n\n");
+
+ if item.has_options() {
+ let ordered = ordered_options(item, form, &placement.item);
+ for (position, source) in ordered.iter().enumerate() {
+ let mark = if source.correct { " ✓" } else { "" };
+ let note = source
+ .student_text()
+ .map(|t| format!(": {}", markup::to_markdown(t)))
+ .unwrap_or_default();
+ out.push_str(&format!(
+ "{}. {}{mark}{note}\n",
+ letter(position),
+ markup::to_markdown(&source.text)
+ ));
+ }
+ out.push('\n');
+ }
+
+ solution_body(out, item);
+ objectives_line(out, item, course);
+ review_line(out, item, course);
+ out.push('\n');
+}
+
+/// The model answer, explanation, rubric, and accepted answers, when present.
+fn solution_body(out: &mut String, item: &Item) {
+ let Some(solution) = item.solution.as_ref().filter(|s| !s.is_empty()) else {
+ if !item.has_options() {
+ // An open-response question with no written solution is unfinished, and
+ // saying so in the document is more useful than a silent blank.
+ out.push_str("_No solution written yet._\n\n");
+ }
+ return;
+ };
+
+ if let Some(answer) = &solution.model_answer {
+ out.push_str(&format!(
+ "**Model answer.** {}\n\n",
+ markup::to_markdown(answer)
+ ));
+ }
+ if let Some(explanation) = &solution.explanation {
+ out.push_str(&markup::to_markdown(explanation));
+ out.push_str("\n\n");
+ }
+ if !solution.rubric.is_empty() {
+ out.push_str("**Rubric**\n\n");
+ for criterion in &solution.rubric {
+ let points = criterion
+ .points
+ .map(|p| format!(" ({} pt)", trim_number(p)))
+ .unwrap_or_default();
+ out.push_str(&format!(
+ "- {}{points}\n",
+ markup::to_markdown(&criterion.description)
+ ));
+ }
+ out.push('\n');
+ }
+ if !solution.accepted.is_empty() {
+ let joined: Vec = solution
+ .accepted
+ .iter()
+ .map(|a| markup::to_markdown(a))
+ .collect();
+ out.push_str(&format!("**Accepted answers:** {}\n\n", joined.join("; ")));
+ }
+}
+
+/// The `Tests:` line naming the objectives this item measures.
+fn objectives_line(out: &mut String, item: &Item, course: &CourseFile) {
+ if item.learning_objectives.is_empty() {
+ return;
+ }
+ let texts: Vec = item
+ .learning_objectives
+ .iter()
+ .map(|id| course.objective_text(id))
+ .collect();
+ out.push_str(&format!("**Tests:** {}\n\n", texts.join("; ")));
+}
+
+/// The `Review:` line, resolving each citation to a short label, linked when a URL
+/// resolves.
+fn review_line(out: &mut String, item: &Item, course: &CourseFile) {
+ let Some(solution) = item.solution.as_ref() else {
+ return;
+ };
+ if solution.review.is_empty() {
+ return;
+ }
+ let cites: Vec = solution.review.iter().map(|c| cite(course, c)).collect();
+ out.push_str(&format!("**Review:** {}\n\n", cites.join("; ")));
+}
+
+/// Resolves one citation to Markdown, mirroring the lecture reading style
+/// `` `KKW` [§6.1](url) ``.
+fn cite(course: &CourseFile, citation: &Citation) -> String {
+ if let Some(text) = &citation.text {
+ if citation.reference.is_none() {
+ return text.clone();
+ }
+ }
+ let Some(key) = &citation.reference else {
+ return citation.display();
+ };
+ let Some(reference) = course.references.get(key) else {
+ return citation.display();
+ };
+ let label = reference.label.as_deref().unwrap_or(key);
+ let locator = citation.locator.as_deref().unwrap_or("");
+ match resolve_url(citation, reference) {
+ Some(url) if !locator.is_empty() => format!("`{label}` [{locator}]({url})"),
+ Some(url) => format!("`{label}` [{}]({url})", reference.title),
+ None if !locator.is_empty() => format!("`{label}` {locator}"),
+ None => format!("`{label}`"),
+ }
+}
+
+/// The URL for a citation: its own `url`, else the reference `base_url` joined with
+/// the citation `path`.
+fn resolve_url(citation: &Citation, reference: &Reference) -> Option {
+ if let Some(url) = &citation.url {
+ return Some(url.clone());
+ }
+ let path = citation.path.as_deref()?;
+ let base = reference.base_url.as_deref()?;
+ Some(match (base.ends_with('/'), path.starts_with('/')) {
+ (true, true) => format!("{base}{}", &path[1..]),
+ (false, false) => format!("{base}/{path}"),
+ _ => format!("{base}{path}"),
+ })
+}
+
+/// The `## Question N` heading, marking a bonus item.
+fn heading(number: usize, placement: &Placement) -> String {
+ let bonus = if placement.bonus { " (bonus)" } else { "" };
+ format!("## Question {number}{bonus}\n\n")
+}
+
+/// The italic level-and-points line under a solutions heading.
+fn meta_line(placement: &Placement, item: &Item) -> String {
+ let level = placement.level.unwrap_or(item.level);
+ let mut parts = vec![format!("Level {} ({})", level.code(), level.name())];
+ if let Some(points) = placement.points {
+ parts.push(format!("{} point(s)", trim_number(points)));
+ }
+ format!("_{}_\n\n", parts.join(" · "))
+}
+
+/// The options in the order the form prints them.
+///
+/// Salted with the item's global id, the same value the Typst and QTI exports use,
+/// so a worksheet built for form B lists options in the order that form's paper and
+/// its Canvas quiz do.
+fn ordered_options<'a>(item: &'a Item, form: &Form, uid: &str) -> Vec<&'a Choice> {
+ select::option_order(form, uid, item.options.len())
+ .into_iter()
+ .map(|i| &item.options[i])
+ .collect()
+}
+
+/// The printed letter for a zero-based position.
+fn letter(position: usize) -> char {
+ (b'A' + (position as u8 % 26)) as char
+}
+
+/// Formats a point value without a trailing `.0`.
+fn trim_number(value: f64) -> String {
+ if value.fract() == 0.0 {
+ format!("{}", value as i64)
+ } else {
+ let s = format!("{value:.2}");
+ s.trim_end_matches('0').trim_end_matches('.').to_string()
+ }
+}
+
+/// Escapes a double quote for a YAML double-quoted scalar.
+fn yaml_quote(s: &str) -> String {
+ s.replace('\\', "\\\\").replace('"', "\\\"")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::assessment::{Assessment, Kind, Platform};
+
+ /// Writes a course and a bank to a temp directory and loads them, the same way
+ /// the catalog tests do, so this exercises only public API. The `tag` keeps each
+ /// test in its own directory, so tests running in parallel do not clobber a
+ /// shared `course.yaml`.
+ fn catalog(tag: &str) -> Catalog {
+ let dir = std::env::temp_dir().join(format!("cb-practice-{tag}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(dir.join("banks")).unwrap();
+ std::fs::write(
+ dir.join("course.yaml"),
+ r#"
+course: { code: BIOSC 1000, title: Biochemistry, term: 2026f }
+references:
+ kkw:
+ label: KKW
+ title: The molecules of life
+ base_url: https://example.org/kkw/
+lectures:
+ L1.1: { title: Enthalpy }
+learning_objectives:
+ lo-enthalpy:
+ text: Define enthalpy and explain the constant-pressure result.
+ lectures: [L1.1]
+ order: 1
+"#,
+ )
+ .unwrap();
+ std::fs::write(
+ dir.join("banks").join("l11.yaml"),
+ r#"
+bank: { id: l11, title: L1.1 }
+items:
+ - id: q-enthalpy-001
+ status: draft
+ level: 1
+ stem: At constant pressure, the heat exchanged equals which quantity?
+ learning_objectives: [lo-enthalpy]
+ options:
+ - { id: A, text: "the enthalpy change", correct: true, feedback_student: "Right: P dV work is folded into H." }
+ - { id: B, text: "the internal energy change", misconception: "ignores expansion work" }
+ - { id: C, text: "zero" }
+ solution:
+ explanation: "Because H = U + PV, at constant P the P dV term is the expansion work, so q_p equals the change in H."
+ review:
+ - { ref: kkw, locator: "§6.4", path: "6/A/#4" }
+ - id: q-enthalpy-op-001
+ status: draft
+ level: 2
+ format: open_response
+ stem: Explain why, at constant pressure, the heat exchanged equals the enthalpy change.
+ learning_objectives: [lo-enthalpy]
+ solution:
+ model_answer: "At constant pressure the P dV expansion work is folded into H = U + PV, so q_p is the change in H."
+ rubric:
+ - { description: "states H = U + PV", points: 1 }
+ - { description: "identifies q_p with the enthalpy change", points: 1 }
+ review:
+ - { ref: kkw, locator: "§6.4", path: "6/A/#4" }
+"#,
+ )
+ .unwrap();
+ Catalog::load(&dir).expect("catalog loads")
+ }
+
+ fn record() -> AssessmentFile {
+ AssessmentFile {
+ schema_version: "1.0".into(),
+ assessment: Assessment {
+ id: "hw-1".into(),
+ title: "Homework 1".into(),
+ term: None,
+ kind: Kind::Homework,
+ date: None,
+ platform: Platform::Canvas,
+ minutes_allowed: None,
+ attempts: None,
+ shuffle: None,
+ scoring_policy: None,
+ instructions: None,
+ notes: None,
+ },
+ blueprint: None,
+ forms: Vec::new(),
+ items: vec![
+ Placement {
+ number: 1,
+ item: "l11::q-enthalpy-001".into(),
+ version: None,
+ fingerprint: None,
+ points: Some(1.0),
+ bonus: false,
+ key: vec!["A".into()],
+ level: None,
+ learning_objectives: Vec::new(),
+ credit_overrides: Default::default(),
+ dropped: false,
+ },
+ Placement {
+ number: 2,
+ item: "l11::q-enthalpy-op-001".into(),
+ version: None,
+ fingerprint: None,
+ points: Some(2.0),
+ bonus: false,
+ key: Vec::new(),
+ level: None,
+ learning_objectives: Vec::new(),
+ credit_overrides: Default::default(),
+ dropped: false,
+ },
+ ],
+ }
+ }
+
+ #[test]
+ fn worksheet_withholds_the_answer() {
+ let md =
+ worksheet(&catalog("worksheet"), &record(), &Options::default().form).expect("renders");
+ assert!(md.contains("## Question 1"));
+ assert!(md.contains("A. the enthalpy change"));
+ // Nothing that reveals the key or the reasoning.
+ assert!(!md.contains('✓'), "no check marks on the worksheet:\n{md}");
+ assert!(!md.contains("Model answer"), "no model answer:\n{md}");
+ assert!(!md.contains("P dV"), "no explanation:\n{md}");
+ assert!(!md.contains("Rubric"));
+ // The open-response question leaves room to write.
+ assert!(md.contains("answer-space"));
+ }
+
+ #[test]
+ fn solutions_show_key_reasoning_rubric_and_review() {
+ let md =
+ solutions(&catalog("solutions"), &record(), &Options::default().form).expect("renders");
+ assert!(md.contains("A. the enthalpy change ✓"));
+ assert!(md.contains("the internal energy change: ignores expansion work"));
+ assert!(md.contains("**Model answer.**"));
+ assert!(md.contains("H = U + PV"));
+ assert!(md.contains("**Rubric**"));
+ assert!(md.contains("states H = U + PV (1 pt)"));
+ assert!(md.contains("Tests:** Define enthalpy"));
+ // The review citation resolves to the label and a link.
+ assert!(
+ md.contains("`KKW` [§6.4](https://example.org/kkw/6/A/#4)"),
+ "{md}"
+ );
+ }
+
+ #[test]
+ fn front_matter_titles_each_document() {
+ let ws = worksheet(
+ &catalog("front-matter-ws"),
+ &record(),
+ &Options::default().form,
+ )
+ .expect("renders");
+ assert!(ws.contains("title: \"Homework 1: Questions\""));
+ let sol = solutions(
+ &catalog("front-matter-sol"),
+ &record(),
+ &Options::default().form,
+ )
+ .expect("renders");
+ assert!(sol.contains("title: \"Homework 1: Solutions\""));
+ }
+}
diff --git a/src/export/qti.rs b/src/export/qti.rs
index 99fa46c..4239c2c 100644
--- a/src/export/qti.rs
+++ b/src/export/qti.rs
@@ -47,9 +47,9 @@ const IMSMD_NS: &str = "http://www.imsglobal.org/xsd/imsmd_v1p2";
const IMSCP_SCHEMA: &str = "http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd \
http://www.imsglobal.org/xsd/imsmd_v1p2 imsmd_v1p2p2.xsd";
-// ---------------------------------------------------------------------------
+// ---
// A very small XML tree
-// ---------------------------------------------------------------------------
+// ---
/// One XML element.
#[derive(Debug, Clone)]
@@ -155,9 +155,9 @@ fn escape_attr(s: &str) -> String {
escape_text(s).replace('"', """)
}
-// ---------------------------------------------------------------------------
+// ---
// Package construction
-// ---------------------------------------------------------------------------
+// ---
/// Options for a QTI export.
#[derive(Debug, Clone)]
@@ -338,6 +338,11 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> R
///
/// The element.
fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &QtiOptions) -> Node {
+ // An open-response item is an essay in Canvas: no choices, graded by hand.
+ if !item.format.has_options() {
+ return build_essay_item(assessment_id, uid, item, points, opts);
+ }
+
let order = select::option_order(&opts.form, uid, item.options.len());
let ordered: Vec<&crate::item::Choice> = order.iter().map(|i| &item.options[*i]).collect();
@@ -499,6 +504,104 @@ fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &Q
node
}
+/// Builds a Canvas essay item for an open-response question.
+///
+/// An essay has no choices and no automatic score: the `` condition leaves
+/// grading to the instructor. When feedback is on and a model answer exists, it
+/// rides along as general feedback so a student sees it after submitting.
+///
+/// This mapping has not been round-tripped through a live Canvas import in this
+/// build, so verify it against your instance before relying on it for a graded
+/// quiz.
+///
+/// # Arguments
+///
+/// * `assessment_id` - salts the generated ids.
+/// * `uid` - the item's global id.
+/// * `item` - the item.
+/// * `points` - points as administered.
+/// * `opts` - export options.
+///
+/// # Returns
+///
+/// The element.
+fn build_essay_item(
+ assessment_id: &str,
+ uid: &str,
+ item: &Item,
+ points: f64,
+ opts: &QtiOptions,
+) -> Node {
+ let item_meta = Node::new("itemmetadata").child(Node::new("qtimetadata").children(vec![
+ metadata_field("question_type", item.format.qti_type()),
+ metadata_field("points_possible", &format!("{points:.2}")),
+ metadata_field("assessment_question_identifierref", &qti_id(uid)),
+ ]));
+
+ let presentation = Node::new("presentation")
+ .child(mattext(&format!(
+ "
", markup::to_html(text)))),
+ ));
+ }
+
+ node
+}
+
/// A `` pair.
///
/// # Arguments
diff --git a/src/export/report.rs b/src/export/report.rs
index f084047..5c10fc0 100644
--- a/src/export/report.rs
+++ b/src/export/report.rs
@@ -98,7 +98,7 @@ pub fn student(
));
out.push_str(&format!("**{}**\n\n", summary.display_name()));
- // ---------------------------------------------------------------- score
+ // --- score
out.push_str(&format!(
"You scored **{:.1} of {:.1} points ({:.0}%)**",
summary.points, summary.points_possible, summary.percent
@@ -144,7 +144,7 @@ pub fn student(
}
}
- // ----------------------------------------------------------- objectives
+ // --- objectives
if opts.objectives && !summary.objectives.is_empty() {
out.push_str("## What this exam says about each learning objective\n\n");
out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n");
@@ -180,7 +180,7 @@ pub fn student(
}
}
- // --------------------------------------------------------------- levels
+ // --- levels
if opts.levels && summary.levels.len() > 1 {
out.push_str("## Kinds of thinking\n\n");
out.push_str(
@@ -231,7 +231,7 @@ pub fn student(
}
}
- // ---------------------------------------------------------- what to do
+ // --- what to do
if !summary.focus.is_empty() {
out.push_str("## Where to put your time\n\n");
out.push_str("In this order:\n\n");
@@ -274,7 +274,7 @@ pub fn student(
out.push_str(&format!("You have clearly got {}.\n\n", list(&refs)));
}
- // --------------------------------------------------------- missed items
+ // --- missed items
if opts.missed && !summary.missed.is_empty() {
out.push_str("## Question by question\n\n");
out.push_str(
@@ -356,7 +356,7 @@ pub fn cohort(
.unwrap_or_else(|| "date not recorded".into())
));
- // ------------------------------------------------------------- summary
+ // --- summary
let r = &analysis.reliability;
out.push_str("## Summary\n\n");
out.push_str(&format!(
@@ -408,7 +408,7 @@ pub fn cohort(
out.push_str(&format!("> {w}\n\n"));
}
- // ------------------------------------------------------- revise queue
+ // --- revise queue
let queue = analysis.revise_queue();
out.push_str("## What to revise\n\n");
if queue.is_empty() {
@@ -477,7 +477,7 @@ pub fn cohort(
}
}
- // ---------------------------------------------------------- item table
+ // --- item table
out.push_str("## Every item\n\n");
out.push_str(
"| Q | Item | Lv | p | r | D | Blank | Flags |\n|--:|:--|--:|--:|--:|--:|--:|:--|\n",
@@ -535,7 +535,7 @@ pub fn cohort(
}
}
- // -------------------------------------------------------- class gaps
+ // --- class gaps
out.push_str("## Objectives the class did not meet\n\n");
if cohort.class_gaps.is_empty() {
out.push_str("Every assessed objective cleared the mastery threshold.\n\n");
@@ -556,7 +556,7 @@ pub fn cohort(
out.push('\n');
}
- // ------------------------------------------------------ level coverage
+ // --- level coverage
out.push_str("## Coverage and class performance by level\n\n");
let counts = record.level_counts();
out.push_str("| Level | Items | Class rate |\n|:--|--:|--:|\n");
@@ -587,7 +587,7 @@ pub fn cohort(
let _ = bp;
}
- // -------------------------------------------------------- archetypes
+ // --- archetypes
if !cohort.archetypes.is_empty() {
out.push_str("## Patterns across students\n\n");
out.push_str(
diff --git a/src/export/typst/payload.rs b/src/export/typst/payload.rs
index dc966ab..718ffbd 100644
--- a/src/export/typst/payload.rs
+++ b/src/export/typst/payload.rs
@@ -662,11 +662,7 @@ fn calibration(item: &Item) -> Option> {
/// The token for a response format.
fn format_token(format: Format) -> &'static str {
- match format {
- Format::SingleBestAnswer => "single_best_answer",
- Format::MultipleResponse => "multiple_response",
- Format::TrueFalse => "true_false",
- }
+ format.as_str()
}
/// The token for an administration platform.
diff --git a/src/lib.rs b/src/lib.rs
index 4517392..766de78 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -110,7 +110,7 @@ pub use data::{canvas, gradescope, responses, store};
pub use analysis::{calibrate, classical, irt, students};
-pub use export::{lecture, qti, report, typst};
+pub use export::{lecture, practice, qti, report, typst};
pub use catalog::Catalog;
pub use course::{CourseFile, SCHEMA_VERSION};
diff --git a/src/model/bank.rs b/src/model/bank.rs
index b7569a5..a09b58d 100644
--- a/src/model/bank.rs
+++ b/src/model/bank.rs
@@ -352,10 +352,20 @@ fn validate_item(
issues.push("version must be at least 1".into());
}
- // --- options -----------------------------------------------------------
- if it.options.len() < 2 {
+ // --- options ---
+ // An open-response item takes no options; its answer lives in `solution`.
+ // Every other format needs at least two things to choose between.
+ if it.format.has_options() {
+ if it.options.len() < 2 {
+ issues.push(format!(
+ "needs at least 2 options, has {}",
+ it.options.len()
+ ));
+ }
+ } else if !it.options.is_empty() {
issues.push(format!(
- "needs at least 2 options, has {}",
+ "{} items take no options, but {} were given; put the answer in `solution`",
+ it.format.as_str(),
it.options.len()
));
}
@@ -418,7 +428,7 @@ fn validate_item(
}
}
- // --- key ---------------------------------------------------------------
+ // --- key -----
let keys = it.key_indices();
match it.format {
Format::SingleBestAnswer => {
@@ -448,9 +458,14 @@ fn validate_item(
issues.push("true_false needs exactly one keyed option".into());
}
}
+ Format::OpenResponse => {
+ if !keys.is_empty() {
+ issues.push("open_response items have no keyed option".into());
+ }
+ }
}
- // --- level and process must agree -------------------------------------
+ // --- level and process must agree --------
if let Some(p) = it.cognitive_process {
if !it.level.allows(p) {
issues.push(format!(
@@ -461,7 +476,7 @@ fn validate_item(
}
}
- // --- design plausibility ----------------------------------------------
+ // --- design plausibility -----------
if let Some(d) = &it.design {
if let Some(x) = d.expected_difficulty {
if !(0.0..=1.0).contains(&x) {
@@ -479,7 +494,7 @@ fn validate_item(
}
}
- // --- calibration plausibility -----------------------------------------
+ // --- calibration plausibility ------
if let Some(c) = &it.calibration {
if let Some(p) = c.p_value {
if !(0.0..=1.0).contains(&p) {
@@ -514,7 +529,7 @@ fn validate_item(
}
}
- // --- history must be coherent -----------------------------------------
+ // --- history must be coherent ------
let mut last_version = 0u32;
for (i, h) in it.history.iter().enumerate() {
if h.version <= last_version {
@@ -533,7 +548,7 @@ fn validate_item(
));
}
- // --- retirement -------------------------------------------------------
+ // --- retirement ----
if it.retired.is_some() && it.status != Status::Retired {
issues.push(format!(
"has a `retired` block but status is `{}`",
@@ -541,7 +556,7 @@ fn validate_item(
));
}
- // --- approval gate ----------------------------------------------------
+ // --- approval gate -------
// Approval is what permits an item onto a graded assessment, so it is the
// right place to require that the item is fully sourced and designed.
if it.status == Status::Approved {
@@ -557,9 +572,23 @@ fn validate_item(
if it.design.is_none() {
issues.push("approved items must carry a design block".into());
}
+ // An open-response item is graded from its solution, so approving one with
+ // neither a model answer nor a rubric would leave nothing to mark it by.
+ if !it.format.has_options() {
+ let gradeable = it
+ .solution
+ .as_ref()
+ .is_some_and(|s| s.model_answer.is_some() || !s.rubric.is_empty());
+ if !gradeable {
+ issues.push(
+ "approved open_response items need a solution with a model_answer or a rubric"
+ .into(),
+ );
+ }
+ }
}
- // --- cross-file references --------------------------------------------
+ // --- cross-file references ---------
if let Some(c) = course {
for lo in &it.learning_objectives {
match c.learning_objectives.get(lo) {
@@ -592,6 +621,15 @@ fn validate_item(
issues.push(format!("unknown stimulus `{st}`"));
}
}
+ // A citation that names a reference key must name a real one, so a review
+ // pointer in the solutions document never resolves to nothing.
+ for citation in it.solution.iter().flat_map(|s| &s.review) {
+ if let Some(key) = &citation.reference {
+ if !c.references.contains_key(key) {
+ issues.push(format!("solution.review cites unknown reference `{key}`"));
+ }
+ }
+ }
if let Some(floor) = c.policy.partial_credit_floor_level {
for o in &it.options {
if o.is_partial() && it.level < floor {
@@ -644,6 +682,53 @@ mod tests {
assert!(b.validate(None).is_empty(), "{:?}", b.validate(None));
}
+ #[test]
+ fn open_response_validates_without_options_and_rejects_them() {
+ // No options is fine, and no key is required.
+ let ok = bank(
+ r#"
+ - id: q-a-op-001
+ status: draft
+ level: 2
+ format: open_response
+ stem: Explain the first law.
+ solution:
+ model_answer: Energy is conserved.
+"#,
+ );
+ assert!(ok.validate(None).is_empty(), "{:?}", ok.validate(None));
+
+ // Giving an open-response item options is the mistake, and so is approving
+ // one with nothing to grade it by.
+ let bad = bank(
+ r#"
+ - id: q-a-op-002
+ status: approved
+ level: 2
+ format: open_response
+ cognitive_process: explain
+ learning_objectives: [lo-x]
+ sources: [{ lecture: L1.1 }]
+ design: { rationale: r }
+ stem: Explain the first law.
+ options:
+ - { id: A, text: a, correct: true }
+ - { id: B, text: b }
+"#,
+ );
+ let issues = bad.validate(None);
+ assert!(
+ issues.iter().any(|i| i.contains("take no options")),
+ "{issues:?}"
+ );
+ assert!(
+ issues
+ .iter()
+ .any(|i| i.contains("model_answer or a rubric")),
+ "{issues:?}"
+ );
+ }
+
#[test]
fn catches_missing_and_multiple_keys() {
let b = bank(
diff --git a/src/model/item.rs b/src/model/item.rs
index b1bd877..fb8bc82 100644
--- a/src/model/item.rs
+++ b/src/model/item.rs
@@ -19,7 +19,11 @@
//! it. That keeps bank files readable and reviewable in a pull request while
//! still letting statistics accumulate across terms.
-use serde::{Deserialize, Serialize};
+use std::fmt;
+
+use serde::de::{self, MapAccess, Visitor};
+use serde::ser::SerializeMap;
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::date::Date;
use crate::hash::fingerprint;
@@ -79,8 +83,25 @@ pub struct Item {
/// The answer options in canonical order. Shuffling happens at export time
/// per form, never here, so the bank stays diffable.
+ ///
+ /// Empty for a [`Format::OpenResponse`] item, which is answered in free text
+ /// and graded from its [`Solution`] instead. A choice format must still supply
+ /// at least two, which [`crate::bank::BankFile::validate`] enforces; leaving
+ /// them out is reported there, with every other problem, rather than failing
+ /// the parse on its own.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec,
+ /// The worked solution: a model answer, an explanation a student can learn
+ /// from, and, for an open-response item, the rubric it is graded against.
+ ///
+ /// This is what the solutions document renders and what a paper answer key
+ /// prints. It is withheld from any question paper and from the exam payload,
+ /// the same way an option's `correct` flag is, so a document built for the
+ /// student cannot leak it.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub solution: Option,
+
/// Objectives this item measures, as ids into the course registry.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec,
@@ -239,6 +260,214 @@ pub struct Source {
pub recording_seconds: Option,
}
+/// A pointer from an item into the course reference registry: where to read more,
+/// or what to revisit after missing the item.
+///
+/// It holds a citation key and a locator rather than a restated citation, so a
+/// reference is written once in `course.yaml` and a changed edition is a single
+/// edit. The exporters resolve it against
+/// [`crate::course::CourseFile::references`] into a short label such as `KKW §6.1`,
+/// linked when the location resolves to a URL. This is the same pointer a lecture
+/// [`crate::course::Reading`] uses, kept lean here because an item cites a reading;
+/// it does not restate one.
+///
+/// A citation may also be written as a bare string, which lands unparsed in `text`
+/// and serializes back out as a string, so a bank that stored readings as plain
+/// strings keeps loading and round-trips byte-for-byte.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct Citation {
+ /// Citation key into the course reference registry.
+ pub reference: Option,
+ /// Where inside the work: `§6.1`, `pp. 212-219`, `fig. 4`.
+ pub locator: Option,
+ /// Appended to the reference's `base_url` to reach this location.
+ pub path: Option,
+ /// A full URL, when the location is not under the reference's `base_url`.
+ pub url: Option,
+ /// A citation written as a bare string, held unparsed.
+ pub text: Option,
+}
+
+impl Citation {
+ /// A short display string that needs no reference lookup.
+ ///
+ /// Prefers the unparsed `text`, then the key and locator. A caller that holds
+ /// the course, such as an exporter, can resolve a nicer label and a link; this
+ /// is the fallback for one that does not.
+ ///
+ /// # Returns
+ ///
+ /// The display string, empty when the citation carries nothing.
+ pub fn display(&self) -> String {
+ if let Some(text) = &self.text {
+ return text.clone();
+ }
+ match (&self.reference, &self.locator) {
+ (Some(k), Some(l)) => format!("{k} {l}"),
+ (Some(k), None) => k.clone(),
+ (None, Some(l)) => l.clone(),
+ (None, None) => String::new(),
+ }
+ }
+}
+
+/// Writes a citation as a mapping, or as a bare string when that is all it holds.
+impl Serialize for Citation {
+ fn serialize(&self, s: S) -> std::result::Result {
+ if let Some(text) = &self.text {
+ if self.reference.is_none()
+ && self.locator.is_none()
+ && self.path.is_none()
+ && self.url.is_none()
+ {
+ return s.serialize_str(text);
+ }
+ }
+ let mut map = s.serialize_map(None)?;
+ if let Some(v) = &self.reference {
+ map.serialize_entry("ref", v)?;
+ }
+ if let Some(v) = &self.locator {
+ map.serialize_entry("locator", v)?;
+ }
+ if let Some(v) = &self.path {
+ map.serialize_entry("path", v)?;
+ }
+ if let Some(v) = &self.url {
+ map.serialize_entry("url", v)?;
+ }
+ if let Some(v) = &self.text {
+ map.serialize_entry("text", v)?;
+ }
+ map.end()
+ }
+}
+
+/// Accepts a citation written either as a mapping or as a bare string.
+impl<'de> Deserialize<'de> for Citation {
+ fn deserialize>(d: D) -> std::result::Result {
+ /// The mapping form, with the field set kept in one place.
+ #[derive(Deserialize)]
+ #[serde(deny_unknown_fields)]
+ struct Mapping {
+ #[serde(rename = "ref", default)]
+ reference: Option,
+ #[serde(default)]
+ locator: Option,
+ #[serde(default)]
+ path: Option,
+ #[serde(default)]
+ url: Option,
+ #[serde(default)]
+ text: Option,
+ }
+
+ struct V;
+ impl<'a> Visitor<'a> for V {
+ type Value = Citation;
+
+ fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str("a citation mapping with a `ref`, or a plain citation string")
+ }
+
+ fn visit_str(self, v: &str) -> std::result::Result {
+ Ok(Citation {
+ text: Some(v.to_string()),
+ ..Citation::default()
+ })
+ }
+
+ fn visit_map>(
+ self,
+ map: M,
+ ) -> std::result::Result {
+ let m = Mapping::deserialize(de::value::MapAccessDeserializer::new(map))?;
+ Ok(Citation {
+ reference: m.reference,
+ locator: m.locator,
+ path: m.path,
+ url: m.url,
+ text: m.text,
+ })
+ }
+ }
+ d.deserialize_any(V)
+ }
+}
+
+/// One line of a grading rubric for an open-response item.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct RubricCriterion {
+ /// What earns the points, e.g. "states H = U + PV" or "compares to ~2.5 kJ/mol".
+ pub description: String,
+ /// Points for this line. Absent lets a grader decide; when present, the lines
+ /// are meant to sum to the item's point value.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub points: Option,
+}
+
+/// The worked solution to an item: what the answer is, why, and how it is graded.
+///
+/// One place, versioned with the question, holds everything a student learns from
+/// after the fact and everything a grader marks an open response against. For a
+/// choice item the per-option [`Choice::explanation`] says why each option is right
+/// or wrong; the solution adds the single worked line of reasoning a solutions
+/// document leads with. For an [`Format::OpenResponse`] item the solution is the
+/// whole answer, because there are no options to annotate.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Solution {
+ /// The model answer, in the authoring markup. For an open-response item this is
+ /// the response a full-credit student would write; for a choice item it is an
+ /// optional one-line statement of the key in words.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub model_answer: Option,
+ /// The worked reasoning a student can learn from: the derivation, the estimate,
+ /// the argument for the key over its neighbours. This is the body of the
+ /// solutions document.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub explanation: Option,
+ /// How an open response is graded, one criterion per line.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub rubric: Vec,
+ /// Responses a short constructed answer would be accepted as. Shown in the
+ /// solutions document as accepted answers, and the hook for automated grading
+ /// later.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub accepted: Vec,
+ /// Where to look again after missing this item, as citations into the course
+ /// reference registry. Resolved and linked by the exporters.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub review: Vec,
+}
+
+impl Solution {
+ /// Whether the solution carries anything worth rendering.
+ ///
+ /// Used to decide whether a solutions entry has a body to print, so an item
+ /// with an empty `solution:` block is treated as having none.
+ pub fn is_empty(&self) -> bool {
+ self.model_answer.is_none()
+ && self.explanation.is_none()
+ && self.rubric.is_empty()
+ && self.accepted.is_empty()
+ && self.review.is_empty()
+ }
+
+ /// Total of the rubric line points, when every line carries one.
+ ///
+ /// # Returns
+ ///
+ /// The sum, or `None` if any line omits its points or the rubric is empty.
+ pub fn rubric_points(&self) -> Option {
+ if self.rubric.is_empty() {
+ return None;
+ }
+ self.rubric.iter().map(|c| c.points).sum::
\n",
- inline_html(explanation)
- ));
+ out.push_str(&explain_html(explanation));
}
}
@@ -377,7 +374,8 @@ fn choice_fragment(course: &CourseFile, placement: &Placement, item: &Item, form
out
}
-/// An open-response fragment: the model answer, the rubric, accepted variants.
+/// An open-response fragment: the model answer, the explanation, the rubric, and
+/// the accepted variants.
fn open_fragment(course: &CourseFile, item: &Item) -> Option {
let solution = item.solution.as_ref().filter(|s| !s.is_empty())?;
let mut out = String::new();
@@ -389,6 +387,10 @@ fn open_fragment(course: &CourseFile, item: &Item) -> Option {
));
}
+ if let Some(explanation) = &solution.explanation {
+ out.push_str(&explain_html(explanation));
+ }
+
if !solution.rubric.is_empty() {
let caption = match solution.rubric_points() {
Some(total) => {
@@ -593,6 +595,18 @@ fn block_html(src: &str) -> String {
.join("")
}
+/// Renders a solution explanation as one `
` per blank-line-
+/// separated paragraph. A single-paragraph explanation emits exactly one such
+/// paragraph, unchanged from before; a multi-paragraph one keeps its breaks, and
+/// every paragraph carries the class `questions.css` already styles.
+fn explain_html(src: &str) -> String {
+ src.split("\n\n")
+ .map(str::trim)
+ .filter(|p| !p.is_empty())
+ .map(|p| format!("
{}
\n", inline_html(p)))
+ .collect()
+}
+
// --- the encrypted bundle -----
/// The encrypted solutions bundle, matching the `solutions.js` v1 format.
@@ -805,6 +819,10 @@ items:
learning_objectives: [lo-enthalpy]
solution:
model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$."
+ explanation: |
+ At constant pressure the pressure-volume work is folded into H, so the heat equals the change in H.
+
+ That is why a calorimeter run at constant pressure reads the enthalpy change directly.
rubric:
- { description: "States $H = U + PV$.", points: 1 }
- { description: "Reaches $q_p = \\Delta H$.", points: 1 }
@@ -914,6 +932,31 @@ items:
assert!(open.contains("Also accepted"));
}
+ #[test]
+ fn an_open_fragment_shows_the_model_answer_and_the_explanation() {
+ // Both fields render, in that order, so an open-response solution reads as
+ // the answer followed by the reasoning, the same as a choice fragment. A
+ // multi-paragraph explanation keeps its breaks as separate paragraphs.
+ let cat = catalog("open-explain");
+ let rec = record();
+ let frags = solution_fragments(&cat, &rec, &form()).unwrap();
+ let open = &frags.iter().find(|(id, _)| id == "q-open").unwrap().1;
+ assert!(open.contains("
").count(),
2,
"each explanation paragraph is its own styled
:\n{open}"
);
- assert!(open.contains("folded into H"), "first paragraph present:\n{open}");
+ assert!(
+ open.contains("folded into H"),
+ "first paragraph present:\n{open}"
+ );
assert!(
open.contains("reads the enthalpy change directly"),
"second paragraph present:\n{open}"
);
let model_at = open.find("sol-model").unwrap();
let explain_at = open.find("sol-explain").unwrap();
- assert!(model_at < explain_at, "model answer comes before the explanation");
+ assert!(
+ model_at < explain_at,
+ "model answer comes before the explanation"
+ );
}
#[test]
@@ -1016,4 +1025,4 @@ items:
assert!(assets[0].1.contains(".qsol"));
assert!(assets[1].1.contains("AES-GCM"));
}
-}
\ No newline at end of file
+}
diff --git a/src/export/typst/config.rs b/src/export/typst/config.rs
index 2c8675c..59aa16c 100644
--- a/src/export/typst/config.rs
+++ b/src/export/typst/config.rs
@@ -607,6 +607,8 @@ defaults:
extra:
accent: '#017ab9'
font: 'Libertinus Serif'
+ show-bubbles: true
+ bubble-radius: '0.42em'
variants:
exam:
diff --git a/src/export/typst/payload.rs b/src/export/typst/payload.rs
index 718ffbd..a8ba3fb 100644
--- a/src/export/typst/payload.rs
+++ b/src/export/typst/payload.rs
@@ -1070,8 +1070,61 @@ fn numeric_map(map: &BTreeMap) -> Value {
)
}
-/// Emits authored markup as either a content block or a quoted string.
+/// Rewrites inline LaTeX math (`$...$`) into a call to mitex's `mi`, so Typst's
+/// own math grammar never has to parse it. Typst's `\times`, `\Delta`, `\ln` and
+/// friends are not valid Typst math — a bare backslash escapes the next
+/// character instead of naming a symbol — which is why equations compile but
+/// print wrong instead of failing outright. `\$` is left alone, matching LaTeX's
+/// own convention for a literal dollar sign, and a `$` with no matching close is
+/// left alone too, rather than swallowing the rest of the field.
+fn rewrite_latex_math(source: &str) -> String {
+ let chars: Vec<(usize, char)> = source.char_indices().collect();
+ let mut out = String::with_capacity(source.len());
+ let mut i = 0;
+ while i < chars.len() {
+ let (_, c) = chars[i];
+ if c == '\\' && i + 1 < chars.len() {
+ out.push('\\');
+ out.push(chars[i + 1].1);
+ i += 2;
+ continue;
+ }
+ if c != '$' {
+ out.push(c);
+ i += 1;
+ continue;
+ }
+ let mut j = i + 1;
+ let close = loop {
+ if j >= chars.len() {
+ break None;
+ }
+ match chars[j].1 {
+ '\\' => j += 2,
+ '$' => break Some(j),
+ _ => j += 1,
+ }
+ };
+ match close {
+ Some(close_idx) => {
+ let start = chars[i + 1].0;
+ let end = chars[close_idx].0;
+ out.push_str("#mi(");
+ out.push_str(&Value::str(&source[start..end]).to_typst(0));
+ out.push(')');
+ i = close_idx + 1;
+ }
+ None => {
+ out.push('$');
+ i += 1;
+ }
+ }
+ }
+ out
+}
+
fn markup_value(source: &str, content: bool) -> Value {
+ let source = rewrite_latex_math(source);
if content {
Value::content(source)
} else {
@@ -1221,3 +1274,17 @@ mod tests {
assert!(!balanced("closing ] first"));
}
}
+
+#[test]
+fn latex_math_becomes_a_mitex_call() {
+ assert_eq!(
+ rewrite_latex_math("angle $\\phi$ (phi)"),
+ "angle #mi(\"\\\\phi\") (phi)"
+ );
+}
+
+#[test]
+fn escaped_and_unmatched_dollar_signs_are_left_alone() {
+ assert_eq!(rewrite_latex_math("costs \\$5 total"), "costs \\$5 total");
+ assert_eq!(rewrite_latex_math("just $5"), "just $5");
+}
diff --git a/src/export/typst/templates/answer-sheet.typ b/src/export/typst/templates/answer-sheet.typ
index 7258c86..be776fd 100644
--- a/src/export/typst/templates/answer-sheet.typ
+++ b/src/export/typst/templates/answer-sheet.typ
@@ -12,6 +12,8 @@
// templates/typst.yaml under `extra` rather than editing the geometry here, and
// check one printed page against your scanner before running a class through it.
+#import "@preview/mitex:0.2.7": mi
+
// coursebank:begin data
#let cb-data = (
course: (code: "COURSE 101", title: "Sample Course", term: "2026s"),
diff --git a/src/export/typst/templates/exam.typ b/src/export/typst/templates/exam.typ
index bdad42a..fa803ca 100644
--- a/src/export/typst/templates/exam.typ
+++ b/src/export/typst/templates/exam.typ
@@ -24,6 +24,8 @@
// copy — export the `key` variant instead, or the day you forget an `if` is the
// day the class gets the answers.
+#import "@preview/mitex:0.2.7": mi
+
// ─────────────────────────────────────────────────────────────────────────────
// Metadata
// ─────────────────────────────────────────────────────────────────────────────
@@ -52,12 +54,14 @@
// how a course changes the look without editing this file at all.
#let extra = cb-meta.at("extra", default: (:))
#let accent = rgb(extra.at("accent", default: "#1f4e79"))
-#let body-font = extra.at("font", default: "Libertinus Serif")
+#let body-font = extra.at("font", default: "Roboto")
#let body-size = eval(extra.at("font-size", default: "11pt"))
#let paper = extra.at("paper", default: "us-letter")
#let show-name-block = extra.at("name-block", default: true)
#let show-points = extra.at("show-points", default: true)
#let page-per-item = extra.at("page-per-item", default: false)
+#let show-bubbles = extra.at("show-bubbles", default: true)
+#let bubble-radius = eval(extra.at("bubble-radius", default: "0.42em"))
#let form-note = if cb-meta.form.at("count", default: 1) > 1 {
" · Form " + cb-meta.form.id
@@ -120,6 +124,11 @@
}
}
+// An unfilled bubble a student marks by hand. `bubble-radius` is the same knob
+// the standalone answer sheet reads, so the two stay visually consistent if you
+// ever generate both.
+#let bubble() = circle(radius: bubble-radius, stroke: 0.5pt)
+
// ─────────────────────────────────────────────────────────────────────────────
// The renderer
// ─────────────────────────────────────────────────────────────────────────────
@@ -154,11 +163,19 @@
block(inset: (left: 1.2em))[
#for opt in q.at("options", default: ()) {
- grid(
- columns: (1.4em, 1fr),
- gutter: 0.2em,
- [#(opt.letter + ".")], [#markup(opt.text)],
- )
+ if show-bubbles {
+ grid(
+ columns: (1.6em, 1.4em, 1fr),
+ gutter: 0.2em,
+ align(horizon)[#bubble()], [#(opt.letter + ".")], [#markup(opt.text)],
+ )
+ } else {
+ grid(
+ columns: (1.4em, 1fr),
+ gutter: 0.2em,
+ [#(opt.letter + ".")], [#markup(opt.text)],
+ )
+ }
v(0.15em)
}
]
diff --git a/src/export/typst/templates/key.typ b/src/export/typst/templates/key.typ
index 00c6c26..0770de0 100644
--- a/src/export/typst/templates/key.typ
+++ b/src/export/typst/templates/key.typ
@@ -13,6 +13,8 @@
// paper's config, and it is why these are two templates rather than one with a
// flag.
+#import "@preview/mitex:0.2.7": mi
+
// coursebank:begin data
#let cb-data = (
course: (code: "COURSE 101", title: "Sample Course", term: "2026s"),
diff --git a/src/util/markup.rs b/src/util/markup.rs
index ea99217..d2aa64a 100644
--- a/src/util/markup.rs
+++ b/src/util/markup.rs
@@ -139,10 +139,25 @@ pub fn to_markdown(src: &str) -> String {
.to_string()
}
-/// Passes authoring markup through for Typst.
+/// Passes authoring markup through for Typst, translating inline LaTeX math on
+/// the way.
///
-/// The markup is already a Typst subset, so this only normalizes whitespace and
-/// escapes the few characters Typst treats specially in content mode.
+/// Outside math this only escapes the characters Typst treats specially in
+/// content mode: a bare `@` or `<` starts a reference or label.
+///
+/// Math is different. Authors write ordinary LaTeX between `$...$`, and Typst's
+/// own math grammar is not LaTeX's — a backslash escapes the next character
+/// rather than naming a symbol, so `\Delta`, `\times`, `\ln` compile without
+/// error and print wrong. Each `$...$` span is instead handed whole to
+/// mitex's `mi`, which parses LaTeX grammar on purpose: `$\phi$` becomes
+/// `#mi("\\phi")`. The `@`/`<`/`>` escaping above is skipped for anything
+/// inside the span, since it reaches Typst as a string argument, not as
+/// markup — escaping `<` there would corrupt the LaTeX rather than protect
+/// anything.
+///
+/// `\$` is left alone, matching LaTeX's own convention for a literal dollar
+/// sign. A `$` with no matching close is escaped the same way rather than left
+/// to open Typst's own math mode on a stray price or a malformed source line.
///
/// # Arguments
///
@@ -152,17 +167,84 @@ pub fn to_markdown(src: &str) -> String {
///
/// Typst content-mode markup.
pub fn to_typst(src: &str) -> String {
+ let src = src.trim();
+ let chars: Vec<(usize, char)> = src.char_indices().collect();
let mut out = String::with_capacity(src.len());
- for ch in src.trim().chars() {
- match ch {
- // A bare `@` or `<` starts a Typst reference or label.
+ let mut i = 0;
+ while i < chars.len() {
+ let (_, c) = chars[i];
+
+ // An escaped pair is copied verbatim and never reconsidered, so `\$`
+ // can't be mistaken for the start of math and an `\@`/`\<`/`\>` an
+ // author already wrote is not escaped a second time.
+ if c == '\\' && i + 1 < chars.len() {
+ out.push('\\');
+ out.push(chars[i + 1].1);
+ i += 2;
+ continue;
+ }
+
+ if c == '$' {
+ match find_math_close(&chars, i) {
+ Some(close) => {
+ let start = chars[i + 1].0;
+ let end = chars[close].0;
+ out.push_str("#mi(");
+ push_typst_string(&mut out, &src[start..end]);
+ out.push(')');
+ i = close + 1;
+ continue;
+ }
+ None => {
+ out.push_str("\\$");
+ i += 1;
+ continue;
+ }
+ }
+ }
+
+ match c {
'@' => out.push_str("\\@"),
'<' => out.push_str("\\<"),
'>' => out.push_str("\\>"),
+ _ => out.push(c),
+ }
+ i += 1;
+ }
+ out
+}
+
+/// Finds the index into `chars` of the `$` matching the opener at `open`. A
+/// backslash-escaped pair is skipped as a unit, so a `\$` inside the math span
+/// doesn't close it early.
+fn find_math_close(chars: &[(usize, char)], open: usize) -> Option {
+ let mut j = open + 1;
+ while j < chars.len() {
+ match chars[j].1 {
+ '\\' if j + 1 < chars.len() => j += 2,
+ '$' => return Some(j),
+ _ => j += 1,
+ }
+ }
+ None
+}
+
+/// Writes `s` as a quoted Typst string. Kept local, duplicating the five-case
+/// match in `typst::value::write_string`, rather than reaching into the
+/// Typst-specific value writer for one small helper.
+fn push_typst_string(out: &mut String, s: &str) {
+ out.push('"');
+ for ch in s.chars() {
+ match ch {
+ '"' => out.push_str("\\\""),
+ '\\' => out.push_str("\\\\"),
+ '\n' => out.push_str("\\n"),
+ '\r' => out.push_str("\\r"),
+ '\t' => out.push_str("\\t"),
_ => out.push(ch),
}
}
- out
+ out.push('"');
}
/// Escapes the five XML-significant characters.
@@ -423,3 +505,27 @@ mod tests {
assert_eq!(to_markdown("one\n\ntwo"), "one\n\ntwo");
}
}
+
+#[test]
+fn latex_math_becomes_a_mitex_call() {
+ assert_eq!(
+ to_typst("angle $\\phi$ (phi)"),
+ "angle #mi(\"\\\\phi\") (phi)"
+ );
+}
+
+#[test]
+fn comparison_operators_inside_math_are_not_escaped() {
+ assert_eq!(to_typst("$\\Delta H < 0$"), "#mi(\"\\\\Delta H < 0\")");
+}
+
+#[test]
+fn reference_starters_outside_math_are_still_escaped() {
+ assert_eq!(to_typst("see @fig:x and x < y"), "see \\@fig:x and x \\< y");
+}
+
+#[test]
+fn escaped_and_unmatched_dollar_signs_are_left_or_escaped() {
+ assert_eq!(to_typst("costs \\$5 total"), "costs \\$5 total");
+ assert_eq!(to_typst("just $5"), "just \\$5");
+}
--
2.54.0
From 1b6b1f99fda7f4485248e1654323d5d6d25bd922 Mon Sep 17 00:00:00 2001
From: Alex Maldonado
Date: Mon, 14 Sep 2026 05:22:12 -0400
Subject: [PATCH 09/26] feat: cleanup exam template
---
src/export/typst/templates/exam.typ | 188 ++++++++++++++++++++--------
1 file changed, 136 insertions(+), 52 deletions(-)
diff --git a/src/export/typst/templates/exam.typ b/src/export/typst/templates/exam.typ
index fa803ca..1841f2c 100644
--- a/src/export/typst/templates/exam.typ
+++ b/src/export/typst/templates/exam.typ
@@ -61,7 +61,8 @@
#let show-points = extra.at("show-points", default: true)
#let page-per-item = extra.at("page-per-item", default: false)
#let show-bubbles = extra.at("show-bubbles", default: true)
-#let bubble-radius = eval(extra.at("bubble-radius", default: "0.42em"))
+#let bubble-radius = eval(extra.at("bubble-radius", default: "0.5em"))
+#let scratch-space = eval(extra.at("scratch-space", default: "1.5em"))
#let form-note = if cb-meta.form.at("count", default: 1) > 1 {
" · Form " + cb-meta.form.id
@@ -76,7 +77,7 @@
#cb-meta.course.code · #cb-meta.assessment.title#form-note
],
footer: context text(size: 0.85em)[
- #counter(page).display("Page 1 of 1", both: true)
+ Page #counter(page).display("1 of 1", both: true)
],
)
#set text(font: body-font, size: body-size, lang: "en")
@@ -129,6 +130,31 @@
// ever generate both.
#let bubble() = circle(radius: bubble-radius, stroke: 0.5pt)
+// A small colored badge for a question's cognitive level, in the same visual
+// style as the tier badges on Exam 4. `level` counts up from 1; swap the order
+// of `level-colors` if your taxonomy numbers complexity the other way. Nothing
+// is drawn when a variant withholds `level` (`fields.level: false`), same as
+// any other optional field.
+#let level-colors = (
+ (bg: rgb("#D9E8EE"), fg: rgb("#264653")),
+ (bg: rgb("#DCF6F3"), fg: rgb("#2a9d8f")),
+ (bg: rgb("#FAF2DD"), fg: rgb("#D19F1F")),
+ (bg: rgb("#FBE6E0"), fg: rgb("#E24E29")),
+ (bg: rgb("#F0E9F5"), fg: rgb("#9967B6")),
+)
+
+#let level-tag(q) = {
+ let level = q.at("level", default: none)
+ if level != none {
+ let idx = calc.max(1, calc.min(level, level-colors.len())) - 1
+ let colors = level-colors.at(idx)
+ let label = q.at("level-name", default: "Level " + str(level))
+ box(fill: colors.bg, radius: 3pt, inset: (x: 6pt, y: 3pt))[
+ #text(weight: "bold", size: 0.75em, fill: colors.fg)[#label]
+ ]
+ }
+}
+
// ─────────────────────────────────────────────────────────────────────────────
// The renderer
// ─────────────────────────────────────────────────────────────────────────────
@@ -146,38 +172,44 @@
// The number is the one recorded in the assessment record, not the position on
// the page. Keep it that way: it is the join key to every grading export.
- //
- // Built in code rather than written as `*#q.number.*` because a field access
- // followed by a literal period reads as the start of another field access.
let number-label = str(q.number) + "."
- block(above: 1.2em, below: 0.5em)[
- *#number-label* #points-tag(q) #markup(q.stem)
- ]
-
- if q.at("multi-select", default: false) {
- block(below: 0.4em)[
- #text(size: 0.9em, style: "italic")[Select all that apply.]
+ block(breakable: false)[
+ #block(above: 1.2em, below: 0.3em)[
+ #grid(
+ columns: (1fr, auto),
+ align: (left + horizon, right + horizon),
+ [*#number-label* #points-tag(q)], level-tag(q),
+ )
]
- }
+ #block(below: 1.5em)[#markup(q.stem)]
- block(inset: (left: 1.2em))[
- #for opt in q.at("options", default: ()) {
- if show-bubbles {
- grid(
- columns: (1.6em, 1.4em, 1fr),
- gutter: 0.2em,
- align(horizon)[#bubble()], [#(opt.letter + ".")], [#markup(opt.text)],
- )
- } else {
- grid(
- columns: (1.4em, 1fr),
- gutter: 0.2em,
- [#(opt.letter + ".")], [#markup(opt.text)],
- )
- }
- v(0.15em)
+ #if q.at("multi-select", default: false) {
+ block(below: 0.4em)[
+ #text(size: 0.9em, style: "italic")[Select all that apply.]
+ ]
}
+
+ #block(inset: (left: 1.2em))[
+ #for opt in q.at("options", default: ()) {
+ if show-bubbles {
+ grid(
+ columns: (1.6em, 1.4em, 1fr),
+ gutter: 0.2em,
+ align(top)[#v(-bubble-radius / 2.1) #bubble()], [#(opt.letter + ".")], [#markup(opt.text)],
+ )
+ } else {
+ grid(
+ columns: (1.4em, 1fr),
+ gutter: 0.2em,
+ [#(opt.letter + ".")], [#markup(opt.text)],
+ )
+ }
+ v(0.15em)
+ }
+ ]
+
+ #if scratch-space > 0pt { v(scratch-space) }
]
if page-per-item { pagebreak(weak: true) }
@@ -187,43 +219,95 @@
// The page
// ─────────────────────────────────────────────────────────────────────────────
+#set page(paper: paper, margin: 2cm)
+#set text(font: body-font, size: body-size, lang: "en")
+#set par(justify: false, leading: 0.7em)
+
+// ── Cover page: no header or footer, so it reads as its own sheet. ──
+#set page(header: none, footer: none)
+
+#v(4em)
#align(center)[
- #text(size: 1.4em, weight: "bold", fill: accent)[#cb-meta.assessment.title]\
- #text(size: 0.95em)[
- #cb-meta.course.code — #cb-meta.course.title · #cb-meta.assessment.term
+ #text(weight: "bold", size: 18pt, fill: accent)[
+ #cb-meta.course.code — #cb-meta.course.title
]\
- #text(size: 0.9em)[
+ #text(size: 16pt)[#cb-meta.assessment.title#form-note]\
+ #text(size: 14pt)[
#cb-meta.assessment.at("date", default: "")
#{
let m = cb-meta.assessment.at("minutes-allowed", default: none)
if m != none { " · " + str(int(calc.round(m))) + " minutes" }
}
- #{
- let p = cb-meta.totals.at("points", default: none)
- if p != none { " · " + fmt-points(p) }
- }
- ]
+ ]\
+ #{
+ let p = cb-meta.totals.at("points", default: none)
+ if p != none { text(size: 12pt)[#fmt-points(p)] }
+ }
]
-#if show-name-block {
- block(above: 1em, below: 1.5em)[
- #grid(
- columns: (auto, 1fr, auto, 1fr),
- gutter: 0.6em,
- [*Name*], box(width: 100%, repeat[.]), [*Student ID*], box(width: 100%, repeat[.]),
- )
- ]
-}
+#v(1.5em)
#{
let instructions = cb-meta.assessment.at("instructions", default: none)
- if instructions != none {
- block(fill: luma(245), inset: 8pt, radius: 3pt, width: 100%)[
- #markup(instructions)
- ]
- }
+ if instructions != none [
+ Please read the following instructions carefully before beginning your assessment.
+
+ #v(1em)
+
+ #markup(instructions)
+ ]
}
+#if show-name-block [
+ #v(1.5em)
+
+ I agree to follow the above instructions. I affirm that all work on this
+ assessment will be my own and that I will not give or receive any
+ unauthorized assistance. To have your assessment graded, you must write your
+ name, sign, and provide your student ID below.
+
+ #v(1em)
+
+ #grid(
+ columns: (50%, 50%),
+ rows: 6em,
+ [
+ #v(1em)
+ #line(length: 18em)
+
+ *Name*
+ ],
+ [
+ #v(1em)
+ #line(length: 18em)
+
+ *Signature*
+ ],
+ )
+
+ #line(length: 8em)
+
+ *Student ID*
+]
+
+#pagebreak()
+// Intentionally blank. Pull this sheet and the cover above as one unit if you
+// need the cover gone — nothing on the back of either one is a question.
+#pagebreak()
+
+// ── The exam itself: header and footer turn on here. ──
+#set page(
+ header: text(size: 0.85em)[
+ #cb-meta.course.code · #cb-meta.assessment.title#form-note
+ ],
+ footer: context text(size: 0.85em)[
+ Page #counter(page).display("1 of 1", both: true)
+ ],
+)
+// Restart the printed page count here too, so the footer reads "Page 1 of N"
+// for the exam itself rather than counting the cover and the blank page.
+#counter(page).update(1)
+
// coursebank:begin questions
#render-question((
number: 1,
--
2.54.0
From 6ae993d393c3dee48a83c23150b73ebe7188c59a Mon Sep 17 00:00:00 2001
From: Alex Maldonado
Date: Mon, 14 Sep 2026 13:01:09 -0400
Subject: [PATCH 10/26] fix: improve key
---
src/export/typst/templates/key.typ | 20 +++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/src/export/typst/templates/key.typ b/src/export/typst/templates/key.typ
index 0770de0..2610199 100644
--- a/src/export/typst/templates/key.typ
+++ b/src/export/typst/templates/key.typ
@@ -41,11 +41,29 @@
)
// coursebank:end data
+// ─────────────────────────────────────────────────────────────────────────────
+// Settings
+// ─────────────────────────────────────────────────────────────────────────────
+
+// Anything under `extra` in templates/typst.yaml arrives here untouched, which is
+// how a course changes the look without editing this file at all.
+#let extra = cb-data.at("extra", default: (:))
+#let accent = rgb(extra.at("accent", default: "#1f4e79"))
+#let body-font = extra.at("font", default: "Roboto")
+#let body-size = eval(extra.at("font-size", default: "11pt"))
+#let paper = extra.at("paper", default: "us-letter")
+#let show-name-block = extra.at("name-block", default: true)
+#let show-points = extra.at("show-points", default: true)
+#let page-per-item = extra.at("page-per-item", default: false)
+#let show-bubbles = extra.at("show-bubbles", default: true)
+#let bubble-radius = eval(extra.at("bubble-radius", default: "0.5em"))
+#let scratch-space = eval(extra.at("scratch-space", default: "1.5em"))
+
#let extra = cb-data.at("extra", default: (:))
#let accent = rgb(extra.at("accent", default: "#1f4e79"))
#set page(paper: extra.at("paper", default: "us-letter"), margin: 2cm)
-#set text(size: 10pt)
+#set text(font: body-font, size: body-size, lang: "en")
#let markup(v) = if type(v) == str { eval(v, mode: "markup") } else { v }
#let fmt-points(p) = if p == calc.trunc(p) { str(calc.trunc(p)) } else { str(p) }
--
2.54.0
From 994e9065e8cdc8b721237555c160546bb79c925b Mon Sep 17 00:00:00 2001
From: Alex Maldonado
Date: Sat, 19 Sep 2026 20:06:41 -0400
Subject: [PATCH 11/26] feat: implement reports
---
src/analysis.rs | 1 +
src/analysis/classical.rs | 9 +-
src/analysis/diagnostic.rs | 1163 +++++++++++++++
src/analysis/students.rs | 7 +-
src/cli.rs | 70 +-
src/commands.rs | 1 +
src/commands/analysis.rs | 433 +++++-
src/commands/export.rs | 18 +-
src/commands/handlers.rs | 558 +++++++
src/data.rs | 2 +
src/data/canvas.rs | 6 +
src/data/decode.rs | 803 ++++++++++
src/data/gradescope.rs | 6 +
src/data/intake.rs | 565 +++++++
src/data/responses.rs | 71 +-
src/data/store.rs | 3 +
src/data/store_parquet.rs | 34 +
src/export/typst.rs | 1 +
src/export/typst/config.rs | 44 +-
src/export/typst/diagnostic.rs | 904 ++++++++++++
src/export/typst/payload.rs | 2 +-
src/export/typst/template.rs | 8 +
src/export/typst/templates/cohort-report.typ | 558 +++++++
src/export/typst/templates/student-report.typ | 552 +++++++
src/lib.rs | 6 +-
src/model.rs | 1 +
src/model/layout.rs | 9 +
src/model/seal.rs | 1310 +++++++++++++++++
28 files changed, 7082 insertions(+), 63 deletions(-)
create mode 100644 src/analysis/diagnostic.rs
create mode 100644 src/commands/handlers.rs
create mode 100644 src/data/decode.rs
create mode 100644 src/data/intake.rs
create mode 100644 src/export/typst/diagnostic.rs
create mode 100644 src/export/typst/templates/cohort-report.typ
create mode 100644 src/export/typst/templates/student-report.typ
create mode 100644 src/model/seal.rs
diff --git a/src/analysis.rs b/src/analysis.rs
index 2d24d25..baa66ba 100644
--- a/src/analysis.rs
+++ b/src/analysis.rs
@@ -35,5 +35,6 @@
pub mod calibrate;
pub mod classical;
+pub mod diagnostic;
pub mod irt;
pub mod students;
diff --git a/src/analysis/classical.rs b/src/analysis/classical.rs
index a46f135..846ff25 100644
--- a/src/analysis/classical.rs
+++ b/src/analysis/classical.rs
@@ -406,13 +406,13 @@ pub fn analyze(
let mut credits: BTreeMap> = BTreeMap::new();
let mut blank = 0usize;
for r in &rows {
- if r.selected.is_empty() {
+ if r.chosen().is_empty() {
blank += 1;
continue;
}
// A multiple-response item is credited to the joined set, so that
// "chose A and C" is one response pattern rather than two options.
- let label = r.selected.join("+");
+ let label = r.chosen().join("+");
if let Some(&si) = student_index.get(r.student_key.as_str()) {
chose.entry(label.clone()).or_default().push(si);
}
@@ -775,7 +775,7 @@ fn infer_key(rows: &[&crate::responses::Response]) -> Vec {
let mut out: BTreeSet = BTreeSet::new();
for r in rows {
if r.credit >= 0.999 {
- for letter in &r.selected {
+ for letter in r.chosen() {
out.insert(letter.clone());
}
}
@@ -857,6 +857,7 @@ mod tests {
assessment_id: "a".into(),
date: None,
form: None,
+ form_position: None,
student_key: student.into(),
sid: None,
name: None,
@@ -870,7 +871,9 @@ mod tests {
} else {
vec![letter.to_string()]
},
+ selected_source: vec![],
eliminated: vec![],
+ eliminated_source: vec![],
correct: Some(credit >= 0.999),
credit,
points_possible: 1.0,
diff --git a/src/analysis/diagnostic.rs b/src/analysis/diagnostic.rs
new file mode 100644
index 0000000..25c6361
--- /dev/null
+++ b/src/analysis/diagnostic.rs
@@ -0,0 +1,1163 @@
+// SPDX-License-Identifier: Prosperity-3.0.0
+// Copyright Scientific Computing Studio
+// Source: https://git.scient.ing/education/coursebank
+
+//! What to tell a student, and what to tell yourself.
+//!
+//! [`crate::students`] computes mastery. [`crate::classical`] computes item
+//! statistics. Neither decides what belongs in a document, and that decision is
+//! not a formatting concern: it determines what a student is able to reconstruct
+//! from the page in front of them.
+//!
+//! So this module assembles two view models, and the interesting property of the
+//! first one is what it does not contain.
+//!
+//! # The withheld-question invariant
+//!
+//! [`StudentDiagnostic`] has no field for a stem and no field for option text.
+//! Not an empty one, not one gated behind a flag: the struct has no such field, so
+//! no template can print what it was never given. This is the same argument
+//! [`crate::typst::config::Reveal`] makes about the exam paper, for the same
+//! reason — a flag is one forgotten `if` away from a bad afternoon, and a
+//! diagnostic that carries the questions cannot be handed back before the makeup
+//! exam is given.
+//!
+//! What a student does get, per missed question: the number, its level, the
+//! objectives it measured, whether they answered it, and the feedback written for
+//! the specific option they chose. That last part is why authoring distractors
+//! carefully pays off twice.
+//!
+//! Two consequences of that invariant are worth stating because they are easy to
+//! undo by accident:
+//!
+//! *Only student-facing feedback is used.* [`crate::item::Choice::student_text`]
+//! falls back to `explanation`, which is instructor-facing and routinely says
+//! which option is right. This module reads `feedback_student` and `misconception`
+//! and nothing else.
+//!
+//! *Feedback is looked up by bank letter, not printed letter.* On a shuffled form
+//! those differ, and looking up the printed letter returns another option's
+//! misconception — confident, specific, and about a question the student did not
+//! answer that way. See [`crate::decode`].
+//!
+//! # What to study
+//!
+//! A list of missed objectives is a diagnosis, not a prescription. The study plan
+//! resolves each weak objective through the course's own reading registry, so a
+//! student is pointed at `KKW §6.2` with the sentence you wrote about what to take
+//! from it, rather than at the name of a chapter.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use serde::Serialize;
+
+use crate::assessment::AssessmentFile;
+use crate::catalog::Catalog;
+use crate::classical::Analysis;
+use crate::course::{CourseFile, ReadingRole};
+use crate::irt::Fit;
+use crate::responses::{Response, ResponseSet};
+use crate::students::{Cohort, Mastery, StudentSummary};
+use crate::taxonomy::Level;
+
+/// What to assemble.
+#[derive(Debug, Clone)]
+pub struct Options {
+ /// Whether to compare the student to the class.
+ pub comparison: bool,
+ /// Whether to include the per-question map.
+ ///
+ /// The map names question numbers, never their content. It is what lets a
+ /// student who has their paper back line the two up.
+ pub questions: bool,
+ /// Whether to include the feedback written for the option the student chose.
+ pub feedback: bool,
+ /// How many objectives to build a study plan for.
+ pub focus_limit: usize,
+ /// How many readings to list per objective.
+ pub readings_per_objective: usize,
+ /// Whether to include the IRT ability estimate.
+ pub ability: bool,
+}
+
+impl Default for Options {
+ fn default() -> Options {
+ Options {
+ comparison: true,
+ questions: true,
+ feedback: true,
+ focus_limit: 4,
+ readings_per_objective: 2,
+ ability: false,
+ }
+ }
+}
+
+/// Everything one student's diagnostic says.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct StudentDiagnostic {
+ /// The grouping key, which is a pseudonym when the store is pseudonymized.
+ pub student_key: String,
+ /// The display name, when identifiers were kept.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub name: Option,
+ /// The institutional id, when identifiers were kept.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub sid: Option,
+ /// Which form they sat.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub form: Option,
+ /// Their score.
+ pub score: Score,
+ /// How they compare to the class, when comparison is on.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub standing: Option,
+ /// Per-level performance.
+ pub levels: Vec,
+ /// Per-objective standing, in the course's own order.
+ pub objectives: Vec,
+ /// Objectives they are clearly meeting, worst first among the confident ones.
+ pub strengths: Vec,
+ /// Objectives to work on, worst first.
+ pub focus: Vec,
+ /// One row per question, with no question in it.
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub questions: Vec,
+ /// What to read, grouped by objective.
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub study: Vec,
+}
+
+/// A score, with the denominators spelled out.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct Score {
+ /// Points earned on scored items.
+ pub points: f64,
+ /// Points available on scored items.
+ pub points_possible: f64,
+ /// Percentage on scored items.
+ pub percent: f64,
+ /// Bonus points earned.
+ pub bonus_points: f64,
+ /// Items answered correctly.
+ pub correct: usize,
+ /// Scored items administered.
+ pub n_items: usize,
+}
+
+/// Where a score sits in the class.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct Standing {
+ /// The class mean percentage.
+ pub class_mean: f64,
+ /// The standard deviation of class percentages.
+ pub class_sd: f64,
+ /// A coarse band, never a rank.
+ pub band: String,
+ /// The IRT ability estimate, when asked for.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub theta: Option,
+ /// Its standard error.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub theta_se: Option,
+}
+
+/// One cognitive level.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct LevelRow {
+ /// The level code, 1 through 5.
+ pub level: u8,
+ /// The level name.
+ pub name: String,
+ /// What that level asks of a student, in one phrase.
+ pub blurb: String,
+ /// How many items at this level.
+ pub n_items: usize,
+ /// The student's rate.
+ pub rate: f64,
+ /// The class rate, when comparison is on.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub class_rate: Option,
+ /// A plain-language comparison, when comparison is on.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub comparison: Option,
+}
+
+/// One objective's standing.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct ObjectiveRow {
+ /// The objective id.
+ pub id: String,
+ /// The objective text.
+ pub text: String,
+ /// The unit it belongs to, when the course declares one.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub unit: Option,
+ /// How many items measured it.
+ pub n_items: usize,
+ /// Credit earned across them.
+ pub credit: f64,
+ /// The observed rate.
+ pub rate: f64,
+ /// The lower bound of the 95% Wilson interval.
+ pub lower: f64,
+ /// The upper bound.
+ pub upper: f64,
+ /// The class rate, when comparison is on.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub class_rate: Option,
+ /// The mastery classification: `meeting`, `developing`, `not yet`, or
+ /// `not enough evidence`.
+ pub status: String,
+ /// A compact symbol for the same thing.
+ pub symbol: String,
+ /// Whether the interval, not just the estimate, clears the threshold.
+ pub confident: bool,
+ /// Whether too few items measured it to classify at all. This is a fact about
+ /// the exam, and a report that says so is being honest rather than vague.
+ pub thin_evidence: bool,
+ /// The levels it was assessed at, as codes.
+ pub levels: Vec,
+}
+
+/// An objective named in a list.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct ObjectiveRef {
+ /// The objective id.
+ pub id: String,
+ /// The objective text.
+ pub text: String,
+ /// The observed rate.
+ pub rate: f64,
+ /// How many items measured it.
+ pub n_items: usize,
+}
+
+/// One question, described without being reproduced.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct QuestionRow {
+ /// The recorded question number, which is what is printed on the paper.
+ pub number: u32,
+ /// Where it sat on this student's form, when the forms differ.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub position: Option,
+ /// The level code.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub level: Option,
+ /// The objectives it measured.
+ pub objectives: Vec,
+ /// Whether it was answered correctly.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub correct: Option,
+ /// Credit earned, as a fraction.
+ pub credit: f64,
+ /// Whether it was a bonus question.
+ #[serde(skip_serializing_if = "std::ops::Not::not")]
+ pub bonus: bool,
+ /// Whether the student left it blank.
+ pub blank: bool,
+ /// The share of the class that answered it correctly, when comparison is on.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub class_rate: Option,
+ /// The feedback written for the option this student chose.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub feedback: Option,
+ /// Where the material was taught.
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub taught_in: Vec,
+}
+
+/// What to read about one objective.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct StudyGroup {
+ /// The objective id.
+ pub objective: String,
+ /// The objective text.
+ pub text: String,
+ /// The observed rate, so the list is ordered by need.
+ pub rate: f64,
+ /// The readings.
+ pub readings: Vec,
+}
+
+/// One reading to revisit.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct StudyReading {
+ /// A short citation, e.g. `KKW §6.2`.
+ pub citation: String,
+ /// The lecture it was assigned for.
+ pub lecture: String,
+ /// That lecture's title.
+ pub lecture_title: String,
+ /// A link, when the reference resolves to one.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub url: Option,
+ /// What to take from it, which is the sentence worth quoting at someone who
+ /// missed the objective.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub focus: Option,
+ /// What the section covers.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub summary: Option,
+ /// Whether it was assigned or offered alongside.
+ pub supplemental: bool,
+}
+
+/// Builds one student's diagnostic.
+///
+/// # Arguments
+///
+/// * `summary` - the student's computed summary.
+/// * `cohort` - the class context.
+/// * `catalog` - the loaded course, for objective text, feedback, and readings.
+/// * `set` - the responses, for this student's per-question rows.
+/// * `analysis` - the item analysis, for class rates per question. Optional.
+/// * `opts` - what to include.
+///
+/// # Returns
+///
+/// The diagnostic.
+pub fn student(
+ summary: &StudentSummary,
+ cohort: &Cohort,
+ catalog: &Catalog,
+ set: &ResponseSet,
+ analysis: Option<&Analysis>,
+ opts: &Options,
+) -> StudentDiagnostic {
+ let course = &catalog.course;
+ let rows = set.for_student(&summary.student_key);
+ let form = rows.first().and_then(|r| r.form.clone());
+
+ let levels = summary
+ .levels
+ .iter()
+ .map(|profile| LevelRow {
+ level: profile.level.code(),
+ name: profile.level.name().to_string(),
+ blurb: profile.level.blurb().to_string(),
+ n_items: profile.n_items,
+ rate: profile.rate,
+ class_rate: opts.comparison.then_some(profile.cohort_rate),
+ comparison: opts.comparison.then(|| profile.comparison().to_string()),
+ })
+ .collect();
+
+ let objectives: Vec = summary
+ .objectives
+ .iter()
+ .map(|mastery| ObjectiveRow {
+ id: mastery.objective.clone(),
+ text: mastery.text.clone(),
+ unit: course
+ .learning_objectives
+ .get(&mastery.objective)
+ .and_then(|o| o.unit.clone()),
+ n_items: mastery.n_items,
+ credit: mastery.credit,
+ rate: mastery.rate,
+ lower: mastery.wilson_lower,
+ upper: mastery.wilson_upper,
+ class_rate: opts.comparison.then_some(mastery.cohort_rate),
+ status: mastery.status.label().to_string(),
+ symbol: mastery.status.symbol().to_string(),
+ confident: mastery.confident,
+ thin_evidence: mastery.status == Mastery::NotEnoughEvidence,
+ levels: mastery.levels.iter().map(|l| l.code()).collect(),
+ })
+ .collect();
+
+ let refs = |ids: &[String]| -> Vec {
+ ids.iter()
+ .filter_map(|id| objectives.iter().find(|o| &o.id == id))
+ .map(|o| ObjectiveRef {
+ id: o.id.clone(),
+ text: o.text.clone(),
+ rate: o.rate,
+ n_items: o.n_items,
+ })
+ .collect()
+ };
+
+ let strengths = refs(&summary.strengths);
+ let focus = refs(&summary.focus);
+
+ let class_rates: BTreeMap = analysis
+ .map(|a| a.items.iter().map(|i| (i.number, i.p_value)).collect())
+ .unwrap_or_default();
+
+ let questions = if opts.questions {
+ rows.iter()
+ .map(|row| question_row(row, catalog, &class_rates, opts))
+ .collect()
+ } else {
+ Vec::new()
+ };
+
+ let study = focus
+ .iter()
+ .take(opts.focus_limit)
+ .map(|objective| StudyGroup {
+ objective: objective.id.clone(),
+ text: objective.text.clone(),
+ rate: objective.rate,
+ readings: readings_for(course, &objective.id, opts.readings_per_objective),
+ })
+ .filter(|group| !group.readings.is_empty())
+ .collect();
+
+ StudentDiagnostic {
+ student_key: summary.student_key.clone(),
+ name: summary.name.clone(),
+ sid: summary.sid.clone(),
+ form,
+ score: Score {
+ points: summary.points,
+ points_possible: summary.points_possible,
+ percent: summary.percent,
+ bonus_points: summary.bonus_points,
+ correct: summary.correct,
+ n_items: summary.n_items,
+ },
+ standing: opts.comparison.then(|| Standing {
+ class_mean: cohort.mean_percent,
+ class_sd: cohort.sd_percent,
+ band: summary.band.clone(),
+ theta: opts.ability.then_some(summary.theta).flatten(),
+ theta_se: opts.ability.then_some(summary.theta_se).flatten(),
+ }),
+ levels,
+ objectives,
+ strengths,
+ focus,
+ questions,
+ study,
+ }
+}
+
+/// Builds one question's row.
+///
+/// The feedback lookup uses [`Response::chosen`], which prefers the bank letters
+/// written at ingest. Falling back to the printed letters is right for an
+/// unshuffled form and wrong for a shuffled one, which is why ingest translates
+/// rather than leaving it to here.
+fn question_row(
+ row: &Response,
+ catalog: &Catalog,
+ class_rates: &BTreeMap,
+ opts: &Options,
+) -> QuestionRow {
+ let blank = row.selected.is_empty() && row.eliminated.is_empty();
+ let mut feedback = None;
+ let mut taught_in = Vec::new();
+
+ if let Some(uid) = row.item_ref.as_deref() {
+ if let Some(entry) = catalog.get(uid) {
+ if opts.feedback && row.credit < 0.999 {
+ if let Some(letter) = row.chosen().first() {
+ // `student_text` falls back to `explanation`, which is written
+ // for a grader and often names the right answer. A student
+ // report must not print it.
+ feedback = entry.item.option(letter).and_then(|choice| {
+ choice
+ .feedback_student
+ .clone()
+ .or_else(|| choice.misconception.clone())
+ });
+ }
+ }
+ for source in &entry.item.sources {
+ let title = catalog
+ .course
+ .lectures
+ .get(&source.lecture)
+ .map(|l| l.title.clone())
+ .unwrap_or_else(|| source.lecture.clone());
+ if source.slides.is_empty() {
+ taught_in.push(format!("{} ({})", title, source.lecture));
+ } else {
+ let slides: Vec = source.slides.iter().map(|s| s.to_string()).collect();
+ taught_in.push(format!(
+ "{} ({}), slide{} {}",
+ title,
+ source.lecture,
+ if source.slides.len() == 1 { "" } else { "s" },
+ slides.join(", ")
+ ));
+ }
+ }
+ }
+ }
+
+ QuestionRow {
+ number: row.item_number,
+ position: row.form_position.filter(|p| *p != row.item_number),
+ level: row.level.map(|l| l.code()),
+ objectives: row.learning_objectives.clone(),
+ correct: row.correct,
+ credit: row.credit,
+ bonus: row.bonus,
+ blank,
+ class_rate: opts
+ .comparison
+ .then(|| class_rates.get(&row.item_number).copied())
+ .flatten(),
+ feedback,
+ taught_in,
+ }
+}
+
+/// Resolves an objective to readings.
+///
+/// # Arguments
+///
+/// * `course` - the course registry.
+/// * `objective` - the objective id.
+/// * `limit` - how many readings to keep.
+///
+/// # Returns
+///
+/// The readings, assigned ones first.
+fn readings_for(course: &CourseFile, objective: &str, limit: usize) -> Vec {
+ let mut out = Vec::new();
+ for (lecture_id, reading) in course.readings_for_objective(objective) {
+ let lecture_title = course
+ .lectures
+ .get(lecture_id)
+ .map(|l| l.title.clone())
+ .unwrap_or_else(|| lecture_id.to_string());
+
+ let (citation, url) = match reading.reference.as_deref() {
+ Some(key) => match course.references.get(key) {
+ Some(reference) => (reading.cite(key, reference), reading.resolve_url(reference)),
+ None => (
+ reading
+ .text
+ .clone()
+ .or_else(|| reading.locator.clone())
+ .unwrap_or_else(|| key.to_string()),
+ reading.url.clone(),
+ ),
+ },
+ None => (
+ reading
+ .text
+ .clone()
+ .or_else(|| reading.locator.clone())
+ .unwrap_or_else(|| lecture_title.clone()),
+ reading.url.clone(),
+ ),
+ };
+
+ out.push(StudyReading {
+ citation,
+ lecture: lecture_id.to_string(),
+ lecture_title,
+ url,
+ focus: reading.focus.clone(),
+ summary: reading.summary.clone(),
+ supplemental: reading.role == ReadingRole::Supplemental,
+ });
+ }
+
+ // Assigned before supplemental, otherwise the order the course declares.
+ out.sort_by_key(|r| r.supplemental);
+ out.truncate(limit);
+ out
+}
+
+/// Everything the class diagnostic says.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct CohortDiagnostic {
+ /// How many students sat it.
+ pub n_students: usize,
+ /// How many scored items.
+ pub n_items: usize,
+ /// The score distribution.
+ pub distribution: Distribution,
+ /// Whole-test reliability.
+ pub reliability: ReliabilityRow,
+ /// Per-level class performance.
+ pub levels: Vec,
+ /// Per-objective class performance, worst first.
+ pub objectives: Vec,
+ /// Objectives the class as a whole did not meet.
+ pub gaps: Vec,
+ /// Per-question statistics.
+ pub questions: Vec,
+ /// Questions worth revisiting before reuse, worst first.
+ pub revise: Vec,
+ /// One row per form, when more than one was given.
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub forms: Vec,
+ /// Where the form built differs from the blueprint it was drawn against.
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub blueprint: Vec,
+ /// Response profiles the class falls into.
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub patterns: Vec,
+ /// Cautions about the analysis itself.
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub warnings: Vec,
+}
+
+/// The score distribution, binned.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct Distribution {
+ /// Mean percentage.
+ pub mean: f64,
+ /// Median percentage.
+ pub median: f64,
+ /// Standard deviation.
+ pub sd: f64,
+ /// Lowest percentage.
+ pub min: f64,
+ /// Highest percentage.
+ pub max: f64,
+ /// Counts in ten-point bins, from 0-9 through 90-100.
+ pub bins: Vec,
+}
+
+/// One histogram bin.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct Bin {
+ /// Inclusive lower bound, in percent.
+ pub low: u32,
+ /// Exclusive upper bound, in percent, except the last bin which includes 100.
+ pub high: u32,
+ /// How many students fell in it.
+ pub count: usize,
+}
+
+/// Reliability, flattened for a template.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct ReliabilityRow {
+ /// KR-20, when it could be computed.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub alpha: Option,
+ /// The standard error of measurement, in items.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub sem: Option,
+ /// Mean p-value across items.
+ pub mean_p: f64,
+ /// Mean point-biserial across items that had one.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub mean_point_biserial: Option,
+ /// What the alpha value means for a test this length in a class this size.
+ pub interpretation: String,
+}
+
+/// One level, class-wide.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct CohortLevelRow {
+ /// The level code.
+ pub level: u8,
+ /// The level name.
+ pub name: String,
+ /// How many items sat at this level.
+ pub n_items: usize,
+ /// The class rate.
+ pub rate: f64,
+}
+
+/// One objective, class-wide.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct CohortObjectiveRow {
+ /// The objective id.
+ pub id: String,
+ /// The objective text.
+ pub text: String,
+ /// How many items measured it.
+ pub n_items: usize,
+ /// The class rate.
+ pub rate: f64,
+ /// How many students met it.
+ pub meeting: usize,
+ /// How many students are developing on it.
+ pub developing: usize,
+ /// How many students are not yet meeting it.
+ pub not_yet: usize,
+ /// How many had too few items to classify.
+ pub thin: usize,
+ /// Whether the class rate is below the course's mastery threshold.
+ pub below_threshold: bool,
+}
+
+/// One question, class-wide.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct CohortQuestionRow {
+ /// The recorded question number.
+ pub number: u32,
+ /// The item's global id.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub item: Option,
+ /// The level code.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub level: Option,
+ /// The objectives it measured.
+ pub objectives: Vec,
+ /// Proportion correct.
+ pub p_value: f64,
+ /// Corrected item-total point-biserial.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub point_biserial: Option,
+ /// Upper minus lower group proportion correct.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub discrimination: Option,
+ /// Fraction who left it blank.
+ pub blank_rate: f64,
+ /// The keyed letters.
+ pub key: Vec,
+ /// Per-option selection, in letter order.
+ pub options: Vec,
+ /// Machine-detected problems.
+ pub flags: Vec,
+ /// What those flags mean.
+ pub notes: Vec,
+ /// Per-form proportion correct, when more than one form was given. A gap here
+ /// on one question, with the rest of the exam in step, points at that
+ /// question's permutation rather than at the cohort.
+ #[serde(skip_serializing_if = "BTreeMap::is_empty")]
+ pub by_form: BTreeMap,
+}
+
+/// One option's selection statistics.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct OptionRow {
+ /// The bank letter.
+ pub letter: String,
+ /// How many chose it.
+ pub count: usize,
+ /// The share who chose it.
+ pub rate: f64,
+ /// Whether it is keyed.
+ pub is_key: bool,
+ /// Correlation between choosing it and scoring well elsewhere.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub point_biserial: Option,
+ /// Whether it drew nobody, and is therefore doing no work.
+ pub nonfunctioning: bool,
+}
+
+/// One form's summary.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct FormRow {
+ /// The form id.
+ pub id: String,
+ /// How many students sat it.
+ pub n_students: usize,
+ /// Their mean percentage.
+ pub mean: f64,
+ /// The standard deviation of their percentages.
+ pub sd: f64,
+}
+
+/// One response profile.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub struct PatternRow {
+ /// A label describing the pattern.
+ pub label: String,
+ /// How many students fit it.
+ pub n_students: usize,
+ /// Mean rate at each level, by level code.
+ pub level_means: BTreeMap,
+}
+
+/// Builds the class diagnostic.
+///
+/// # Arguments
+///
+/// * `analysis` - the classical item analysis.
+/// * `cohort` - the per-student summaries and class rates.
+/// * `catalog` - the loaded course.
+/// * `record` - the assessment record, for the blueprint check.
+/// * `set` - the responses, for per-form and per-option breakdowns.
+/// * `fit` - an IRT fit, when one was computed.
+///
+/// # Returns
+///
+/// The diagnostic.
+pub fn cohort(
+ analysis: &Analysis,
+ cohort: &Cohort,
+ catalog: &Catalog,
+ record: &AssessmentFile,
+ set: &ResponseSet,
+ fit: Option<&Fit>,
+) -> CohortDiagnostic {
+ let _ = fit;
+ let course = &catalog.course;
+ let threshold = course.policy.mastery_threshold;
+
+ let percents: Vec = cohort.students.iter().map(|s| s.percent).collect();
+
+ let level_counts: BTreeMap = {
+ let mut counts: BTreeMap> = BTreeMap::new();
+ for row in set.rows.iter().filter(|r| r.counts()) {
+ if let Some(level) = row.level {
+ counts.entry(level).or_default().insert(row.item_number);
+ }
+ }
+ counts.into_iter().map(|(k, v)| (k, v.len())).collect()
+ };
+
+ let levels = Level::ALL
+ .iter()
+ .filter_map(|level| {
+ let rate = cohort.level_rates.get(level).copied()?;
+ Some(CohortLevelRow {
+ level: level.code(),
+ name: level.name().to_string(),
+ n_items: level_counts.get(level).copied().unwrap_or(0),
+ rate,
+ })
+ })
+ .collect();
+
+ // Objective counts come from the responses so that an objective assessed by
+ // two items is not reported as if it had one.
+ let mut objective_items: BTreeMap> = BTreeMap::new();
+ for row in set.rows.iter().filter(|r| r.counts()) {
+ for objective in &row.learning_objectives {
+ objective_items
+ .entry(objective.clone())
+ .or_default()
+ .insert(row.item_number);
+ }
+ }
+
+ let mut objectives: Vec = cohort
+ .objective_rates
+ .iter()
+ .map(|(id, rate)| {
+ let mut meeting = 0;
+ let mut developing = 0;
+ let mut not_yet = 0;
+ let mut thin = 0;
+ for student in &cohort.students {
+ if let Some(row) = student.objectives.iter().find(|o| &o.objective == id) {
+ match row.status {
+ Mastery::Meeting => meeting += 1,
+ Mastery::Developing => developing += 1,
+ Mastery::NotYet => not_yet += 1,
+ Mastery::NotEnoughEvidence => thin += 1,
+ }
+ }
+ }
+ CohortObjectiveRow {
+ id: id.clone(),
+ text: course.objective_text(id),
+ n_items: objective_items.get(id).map(|s| s.len()).unwrap_or(0),
+ rate: *rate,
+ meeting,
+ developing,
+ not_yet,
+ thin,
+ below_threshold: *rate < threshold,
+ }
+ })
+ .collect();
+ objectives.sort_by(|a, b| {
+ a.rate
+ .partial_cmp(&b.rate)
+ .unwrap_or(std::cmp::Ordering::Equal)
+ .then_with(|| a.id.cmp(&b.id))
+ });
+ let gaps: Vec = objectives
+ .iter()
+ .filter(|o| o.below_threshold)
+ .cloned()
+ .collect();
+
+ let by_form = per_form_p_values(set);
+ let item_meta: BTreeMap, Vec)> = record
+ .items
+ .iter()
+ .map(|p| {
+ (
+ p.number,
+ (p.level.map(|l| l.code()), p.learning_objectives.clone()),
+ )
+ })
+ .collect();
+
+ let questions: Vec = analysis
+ .items
+ .iter()
+ .map(|item| {
+ let meta = item_meta.get(&item.number);
+ CohortQuestionRow {
+ number: item.number,
+ item: item.item_ref.clone(),
+ level: meta.and_then(|m| m.0),
+ objectives: meta.map(|m| m.1.clone()).unwrap_or_default(),
+ p_value: item.p_value,
+ point_biserial: item.point_biserial,
+ discrimination: item.discrimination_index,
+ blank_rate: item.blank_rate,
+ key: item.key.clone(),
+ options: item
+ .options
+ .values()
+ .map(|option| OptionRow {
+ letter: option.letter.clone(),
+ count: option.count,
+ rate: option.rate,
+ is_key: option.is_key,
+ point_biserial: option.point_biserial,
+ nonfunctioning: !option.is_key && option.rate <= 0.05,
+ })
+ .collect(),
+ flags: item.flags.iter().map(|f| f.as_str().to_string()).collect(),
+ notes: item.notes.clone(),
+ by_form: by_form.get(&item.number).cloned().unwrap_or_default(),
+ }
+ })
+ .collect();
+
+ let revise: Vec = analysis
+ .revise_queue()
+ .iter()
+ .filter_map(|item| questions.iter().find(|q| q.number == item.number).cloned())
+ .collect();
+
+ CohortDiagnostic {
+ n_students: cohort.students.len(),
+ n_items: analysis.reliability.n_items,
+ distribution: distribution(&percents),
+ reliability: ReliabilityRow {
+ alpha: analysis.reliability.alpha,
+ sem: analysis.reliability.sem,
+ mean_p: analysis.reliability.mean_p,
+ mean_point_biserial: analysis.reliability.mean_point_biserial,
+ interpretation: analysis.reliability.interpretation(),
+ },
+ levels,
+ objectives,
+ gaps,
+ questions,
+ revise,
+ forms: form_rows(set, cohort),
+ blueprint: crate::select::check_blueprint(record),
+ patterns: cohort
+ .archetypes
+ .iter()
+ .map(|a| PatternRow {
+ label: a.label.clone(),
+ n_students: a.members.len(),
+ level_means: a
+ .level_means
+ .iter()
+ .map(|(level, mean)| (level.code(), *mean))
+ .collect(),
+ })
+ .collect(),
+ warnings: analysis.warnings.clone(),
+ }
+}
+
+/// Per-question proportion correct, split by form.
+fn per_form_p_values(set: &ResponseSet) -> BTreeMap> {
+ let forms: BTreeSet<&str> = set.rows.iter().filter_map(|r| r.form.as_deref()).collect();
+ if forms.len() < 2 {
+ return BTreeMap::new();
+ }
+
+ let mut totals: BTreeMap<(u32, String), (usize, usize)> = BTreeMap::new();
+ for row in set.rows.iter().filter(|r| r.counts()) {
+ let Some(form) = row.form.as_deref() else {
+ continue;
+ };
+ let entry = totals
+ .entry((row.item_number, form.to_string()))
+ .or_insert((0, 0));
+ entry.1 += 1;
+ if row.correct == Some(true) {
+ entry.0 += 1;
+ }
+ }
+
+ let mut out: BTreeMap> = BTreeMap::new();
+ for ((number, form), (correct, n)) in totals {
+ if n == 0 {
+ continue;
+ }
+ out.entry(number)
+ .or_default()
+ .insert(form, correct as f64 / n as f64);
+ }
+ out
+}
+
+/// One row per form, when more than one was given.
+fn form_rows(set: &ResponseSet, cohort: &Cohort) -> Vec {
+ let mut students_by_form: BTreeMap> = BTreeMap::new();
+ for row in &set.rows {
+ if let Some(form) = row.form.as_deref() {
+ students_by_form
+ .entry(form.to_string())
+ .or_default()
+ .insert(row.student_key.as_str());
+ }
+ }
+ if students_by_form.len() < 2 {
+ return Vec::new();
+ }
+
+ let percent: BTreeMap<&str, f64> = cohort
+ .students
+ .iter()
+ .map(|s| (s.student_key.as_str(), s.percent))
+ .collect();
+
+ students_by_form
+ .into_iter()
+ .map(|(form, students)| {
+ let values: Vec = students
+ .iter()
+ .filter_map(|s| percent.get(*s).copied())
+ .collect();
+ let n = values.len();
+ let mean = if n == 0 {
+ 0.0
+ } else {
+ values.iter().sum::() / n as f64
+ };
+ let sd = if n < 2 {
+ 0.0
+ } else {
+ let variance =
+ values.iter().map(|v| (v - mean).powi(2)).sum::() / (n as f64 - 1.0);
+ variance.sqrt()
+ };
+ FormRow {
+ id: form,
+ n_students: n,
+ mean,
+ sd,
+ }
+ })
+ .collect()
+}
+
+/// Bins a set of percentages into a distribution.
+fn distribution(percents: &[f64]) -> Distribution {
+ let mut sorted: Vec = percents.to_vec();
+ sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
+
+ let n = sorted.len();
+ let mean = if n == 0 {
+ 0.0
+ } else {
+ sorted.iter().sum::() / n as f64
+ };
+ let median = match n {
+ 0 => 0.0,
+ _ if n % 2 == 1 => sorted[n / 2],
+ _ => (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0,
+ };
+ let sd = if n < 2 {
+ 0.0
+ } else {
+ (sorted.iter().map(|v| (v - mean).powi(2)).sum::() / (n as f64 - 1.0)).sqrt()
+ };
+
+ let mut bins: Vec = (0..10)
+ .map(|i| Bin {
+ low: i * 10,
+ high: if i == 9 { 100 } else { i * 10 + 10 },
+ count: 0,
+ })
+ .collect();
+ for value in &sorted {
+ let index = ((*value / 10.0).floor() as isize).clamp(0, 9) as usize;
+ bins[index].count += 1;
+ }
+
+ Distribution {
+ mean,
+ median,
+ sd,
+ min: sorted.first().copied().unwrap_or(0.0),
+ max: sorted.last().copied().unwrap_or(0.0),
+ bins,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn the_distribution_bins_a_hundred_into_the_last_bin() {
+ let d = distribution(&[100.0, 95.0, 0.0, 42.0]);
+ assert_eq!(d.bins[9].count, 2, "100 belongs with the nineties");
+ assert_eq!(d.bins[0].count, 1);
+ assert_eq!(d.bins[4].count, 1);
+ assert_eq!(d.max, 100.0);
+ assert_eq!(d.min, 0.0);
+ }
+
+ #[test]
+ fn the_median_averages_the_middle_pair() {
+ assert_eq!(distribution(&[10.0, 20.0, 30.0, 40.0]).median, 25.0);
+ assert_eq!(distribution(&[10.0, 20.0, 30.0]).median, 20.0);
+ }
+
+ #[test]
+ fn an_empty_class_does_not_panic() {
+ let d = distribution(&[]);
+ assert_eq!(d.mean, 0.0);
+ assert_eq!(d.bins.len(), 10);
+ }
+
+ #[test]
+ fn the_student_diagnostic_has_no_field_for_question_content() {
+ // A compile-time argument as much as a test: the struct has no stem and no
+ // option text, so no template can print either one. If a field is ever
+ // added, this serialization check is where the reason gets re-read.
+ let json = serde_json::to_string(&StudentDiagnostic {
+ student_key: "s-1".into(),
+ name: None,
+ sid: None,
+ form: None,
+ score: Score {
+ points: 1.0,
+ points_possible: 2.0,
+ percent: 50.0,
+ bonus_points: 0.0,
+ correct: 1,
+ n_items: 2,
+ },
+ standing: None,
+ levels: Vec::new(),
+ objectives: Vec::new(),
+ strengths: Vec::new(),
+ focus: Vec::new(),
+ questions: Vec::new(),
+ study: Vec::new(),
+ })
+ .unwrap();
+ assert!(!json.contains("stem"), "{json}");
+ assert!(!json.contains("options"), "{json}");
+ }
+}
diff --git a/src/analysis/students.rs b/src/analysis/students.rs
index dd153e3..a36b874 100644
--- a/src/analysis/students.rs
+++ b/src/analysis/students.rs
@@ -563,7 +563,7 @@ fn missed_items(
if let Some(entry) = cat.get(uid) {
// Feedback for the specific option chosen, which is the whole
// point of recording per-distractor misconceptions.
- if let Some(letter) = r.selected.first() {
+ if let Some(letter) = r.chosen().first() {
if let Some(choice) = entry.item.option(letter) {
misconception = choice.misconception.clone();
feedback = choice.student_text().map(|s| s.to_string());
@@ -592,7 +592,7 @@ fn missed_items(
out.push(MissedItem {
number: r.item_number,
item_ref: r.item_ref.clone(),
- selected: r.selected.clone(),
+ selected: r.chosen().to_vec(),
credit: r.credit,
level: r.level,
learning_objectives: r.learning_objectives.clone(),
@@ -1089,6 +1089,7 @@ mod tests {
assessment_id: "a".into(),
date: None,
form: None,
+ form_position: None,
student_key: student.into(),
sid: None,
name: None,
@@ -1098,7 +1099,9 @@ mod tests {
item_ref: None,
item_version: None,
selected: vec!["A".into()],
+ selected_source: vec![],
eliminated: vec![],
+ eliminated_source: vec![],
correct: Some(credit >= 0.999),
credit,
points_possible: 1.0,
diff --git a/src/cli.rs b/src/cli.rs
index 452a171..3c5a32b 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -87,6 +87,8 @@ pub(crate) enum Command {
/// Write reports.
#[command(subcommand)]
Report(ReportCommand),
+ /// Freeze what was administered, with digests, before printing.
+ Seal(SealArgs),
/// List what is in the response store.
Data,
}
@@ -551,12 +553,23 @@ impl FormatArg {
#[derive(Debug, Subcommand)]
pub(crate) enum IngestCommand {
- /// Read a directory of Gradescope per-question CSV exports.
+ /// Read one directory of Gradescope per-question CSV exports per form.
+ ///
+ /// Write each source as `FORM=DIR`. A bare path takes `--form`.
Gradescope {
- /// The directory holding 1.csv .. N.csv.
- dir: PathBuf,
+ /// The directories, e.g. A=exports/e1-a B=exports/e1-b.
+ #[arg(value_name = "SOURCE", required = true)]
+ sources: Vec,
#[command(flatten)]
common: IngestCommon,
+ /// Ingest even when a directory's graded keys do not match the form it
+ /// was labelled with.
+ #[arg(long)]
+ allow_mismatch: bool,
+ /// The export numbers questions by recorded number rather than by
+ /// printed position. Only for a platform that is not Gradescope.
+ #[arg(long)]
+ recorded_numbers: bool,
},
/// Read a Canvas "Student Analysis" CSV.
Canvas {
@@ -648,6 +661,24 @@ pub(crate) enum ReportCommand {
/// Leave out the comparison to the class.
#[arg(long)]
no_comparison: bool,
+ /// Also write a Typst diagnostic per student.
+ #[arg(long)]
+ typst: bool,
+ /// Skip the Markdown reports.
+ #[arg(long)]
+ no_markdown: bool,
+ /// Leave the per-question map out of the Typst report.
+ #[arg(long)]
+ no_questions: bool,
+ /// Leave per-option feedback out of the Typst report.
+ #[arg(long)]
+ no_feedback: bool,
+ /// Use this template instead of the usual lookup.
+ #[arg(long)]
+ template: Option,
+ /// Also write each payload as JSON.
+ #[arg(long)]
+ json: bool,
},
/// The instructor's item analysis.
Cohort {
@@ -659,9 +690,42 @@ pub(crate) enum ReportCommand {
/// Output path; defaults to reports/-cohort.md.
#[arg(long)]
out: Option,
+ /// Also write a Typst class diagnostic.
+ #[arg(long)]
+ typst: bool,
+ /// Skip the Markdown report.
+ #[arg(long)]
+ no_markdown: bool,
+ /// Use this template instead of the usual lookup.
+ #[arg(long)]
+ template: Option,
+ /// Also write the payload as JSON.
+ #[arg(long)]
+ json: bool,
},
}
+#[derive(Debug, Args)]
+pub(crate) struct SealArgs {
+ /// Assessment id.
+ pub(crate) id: String,
+ /// Check the existing seal against the course instead of writing one.
+ #[arg(long)]
+ pub(crate) check: bool,
+ /// Overwrite an existing seal.
+ #[arg(long)]
+ pub(crate) force: bool,
+ /// Only these forms; defaults to every form the record declares.
+ #[arg(long, value_delimiter = ',')]
+ pub(crate) form: Vec,
+ /// Store only fingerprints, not the stem and option text.
+ #[arg(long)]
+ pub(crate) no_content: bool,
+ /// Output path; defaults to seals/.yaml.
+ #[arg(long)]
+ pub(crate) out: Option,
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/commands.rs b/src/commands.rs
index 8b98b7f..af2bad0 100644
--- a/src/commands.rs
+++ b/src/commands.rs
@@ -70,6 +70,7 @@ pub(crate) fn run(cli: &Cli) -> Result {
Command::Analyze(sub) => analysis::analyze(cli, sub),
Command::Calibrate(args) => analysis::calibrate(cli, args),
Command::Report(sub) => analysis::report(cli, sub),
+ Command::Seal(args) => analysis::seal(cli, args),
Command::Data => analysis::data(cli),
}
}
diff --git a/src/commands/analysis.rs b/src/commands/analysis.rs
index 6c3b7c7..141f15f 100644
--- a/src/commands/analysis.rs
+++ b/src/commands/analysis.rs
@@ -4,53 +4,240 @@
//! The responses-to-report pipeline.
//!
-//! Once an assessment has been given, responses come back through [`ingest`],
-//! statistics come out of [`analyze`] (classical, IRT, or per-student), [`calibrate`]
-//! writes those statistics back onto the items, and [`report`] produces the
-//! student and cohort documents. [`data`] lists what the response store holds.
+//! [`seal`] freezes what was administered before the papers are printed, which is
+//! what lets everything downstream translate a student's marks back into the
+//! bank's own lettering. Once an assessment has been given, responses come back
+//! through [`ingest`], statistics come out of [`analyze`] (classical, IRT, or
+//! per-student), [`calibrate`] writes those statistics back onto the items, and
+//! [`report`] produces the student and cohort documents. [`data`] lists what the
+//! response store holds.
use coursebank::calibrate;
use coursebank::canvas;
+use coursebank::catalog::Severity;
use coursebank::classical::{self, Thresholds};
-use coursebank::error::Result;
-use coursebank::gradescope;
+use coursebank::decode::Numbering;
+use coursebank::diagnostic;
+use coursebank::error::{Error, Result};
+use coursebank::intake::{self, Source};
use coursebank::irt;
use coursebank::layout::Layout;
use coursebank::report;
+use coursebank::seal::{self, SealFile};
use coursebank::store::{self, Store};
use coursebank::students;
+use coursebank::typst::diagnostic as typst_diagnostic;
+use coursebank::typst::{self, Variant};
use coursebank::yaml;
-use crate::cli::{AnalyzeCommand, CalibrateArgs, Cli, IngestCommand, ReportCommand};
+use crate::cli::{AnalyzeCommand, CalibrateArgs, Cli, IngestCommand, ReportCommand, SealArgs};
use crate::commands::Outcome;
use crate::helpers::{context, load, load_record, read_salt, responses_for, truncate};
-/// `ingest`: read a Gradescope directory or a Canvas CSV into the response store.
+/// `seal`: freeze what was administered, or check the freeze.
///
-/// Enriches the parsed responses against the record, optionally pseudonymizes the
-/// identifiers, and — unless `--dry-run` — writes them in the chosen format.
+/// Write the seal after exporting the papers and before printing them. It records
+/// the stem and options of every item, the printed-letter map for every form, and
+/// digests over both, so that the question "is the bank still what the students
+/// saw?" has an answer six months from now.
+pub(crate) fn seal(cli: &Cli, args: &SealArgs) -> Result {
+ let catalog = load(cli)?;
+ let record = load_record(&catalog, &args.id)?;
+ let path = args
+ .out
+ .clone()
+ .unwrap_or_else(|| SealFile::path(&catalog.layout, &record.assessment.id));
+
+ if args.check {
+ let existing = SealFile::load(&path).map_err(|_| {
+ Error::usage(format!(
+ "no seal at {}. Write one with `coursebank seal {}`",
+ path.display(),
+ args.id
+ ))
+ })?;
+
+ let drift = existing.verify(&catalog, &record);
+ if drift.is_empty() {
+ println!(
+ "{} matches the seal written {} ({})",
+ args.id,
+ existing.seal.sealed_on,
+ seal::short(&existing.seal.digest)
+ );
+ return Ok(Outcome::Ok);
+ }
+
+ println!(
+ "{} finding(s) against the seal written {}:\n",
+ drift.len(),
+ existing.seal.sealed_on
+ );
+ for finding in &drift {
+ println!(" {:<6} {}", finding.severity.label(), finding.message);
+ }
+ if drift.iter().any(|d| d.is_blocking()) {
+ println!(
+ "\nAnalysis that pools this administration with another is comparing two \
+ different questions. Per-option feedback in student reports may name the wrong \
+ option."
+ );
+ }
+ return Ok(Outcome::Findings);
+ }
+
+ if path.exists() && !args.force {
+ return Err(Error::usage(format!(
+ "{} already exists. A seal is meant to be written once, before the exam is printed; \
+ pass --force only if you are re-sealing an assessment that was never administered",
+ path.display()
+ )));
+ }
+
+ let opts = seal::Options {
+ content: !args.no_content,
+ forms: args.form.clone(),
+ };
+ let file = seal::build(&catalog, &record, &opts)?;
+ file.save(&path)?;
+
+ println!(
+ "wrote {} ({} item(s), {} form(s), {})",
+ path.display(),
+ file.items.len(),
+ file.forms.len(),
+ seal::short(&file.seal.digest)
+ );
+ let mut advisories = Vec::new();
+ for form in &file.forms {
+ let permuted = form
+ .questions
+ .iter()
+ .filter(|q| q.options.iter().any(|o| o.printed != o.canonical))
+ .count();
+ println!(
+ " form {}: {} question(s), {} with permuted options, {}",
+ form.id,
+ form.questions.len(),
+ permuted,
+ seal::short(&form.digest)
+ );
+ advisories.extend(seal::balance(form).notes());
+ }
+
+ // The last moment before printing is the only cheap moment to notice that a
+ // shuffle produced a sequence a student will read as a mistake.
+ if !advisories.is_empty() {
+ println!();
+ for note in &advisories {
+ println!("! {note}");
+ }
+ println!(
+ "\nRe-seed a form by editing its `seed:` in the record, then re-export and re-seal \
+ with --force. Nothing else has to change."
+ );
+ }
+
+ if !cli.quiet {
+ println!(
+ "\nCommit this file. Check it any time with: coursebank seal {} --check",
+ args.id
+ );
+ }
+ Ok(Outcome::Ok)
+}
+
+/// `ingest`: read grading exports into the response store.
+///
+/// The Gradescope path takes one directory per form and merges them into a single
+/// administration, translating each form's printed letters into the bank's
+/// lettering on the way in. See [`coursebank::intake`].
pub(crate) fn ingest(cli: &Cli, sub: &IngestCommand) -> Result {
let catalog = load(cli)?;
let (common, mut set) = match sub {
- IngestCommand::Gradescope { dir, common } => {
+ IngestCommand::Gradescope {
+ sources,
+ common,
+ allow_mismatch,
+ recorded_numbers,
+ } => {
let record = load_record(&catalog, &common.assessment)?;
- let ctx = context(&catalog, &record, common)?;
- let import = gradescope::ingest_dir(dir, &ctx)?;
+ let sealed = SealFile::find(&catalog.layout, &record.assessment.id)?;
+
+ if let Some(file) = &sealed {
+ let drift = file.verify(&catalog, &record);
+ let blocking: Vec<&seal::Drift> =
+ drift.iter().filter(|d| d.is_blocking()).collect();
+ for finding in &drift {
+ println!("! {} {}", finding.severity.label(), finding.message);
+ }
+ if !blocking.is_empty() {
+ println!(
+ "\nThe seal still describes the papers the students held, so ingest will \
+ use it. The bank has moved since; `coursebank seal {} --check` lists \
+ what.",
+ common.assessment
+ );
+ }
+ } else if !cli.quiet {
+ println!(
+ "! no seal for {}; the option maps will be derived from the record as it \
+ stands today. Write one next time with `coursebank seal {}` before printing",
+ common.assessment, common.assessment
+ );
+ }
+
+ let mut parsed = Vec::new();
+ for text in sources {
+ parsed.push(Source::parse(text, common.form.as_deref())?);
+ }
+ for source in &parsed {
+ if !intake::looks_like_export(&source.dir) {
+ return Err(Error::usage(format!(
+ "{} holds no files named `1.csv` … `N.csv`. Gradescope writes one file \
+ per question; point at the directory those were unzipped into",
+ source.dir.display()
+ )));
+ }
+ }
+
+ let date = match &common.date {
+ Some(text) => Some(text.parse()?),
+ None => None,
+ };
+ let opts = intake::Options {
+ date,
+ numbering: if *recorded_numbers {
+ Numbering::Recorded
+ } else {
+ Numbering::Printed
+ },
+ strict: !*allow_mismatch,
+ };
+
+ let result = intake::run(&catalog, &record, sealed.as_ref(), &parsed, &opts)?;
+
+ for form in &result.forms {
+ println!("{}", intake::describe(form));
+ }
// Grading-time partial credit is an ambiguity signal worth surfacing
- // right here, while the exam is fresh.
- for question in &import.questions {
+ // while the exam is fresh, and it is per form because a regrade
+ // applied to one form and not the other is its own problem.
+ for (form, question) in result.questions() {
for (letter, value, note) in question.partial_credit() {
println!(
- "! q{}: option {letter} earned {value} of {} points at grading time{}",
+ "! form {form} q{}: option {letter} earned {value} of {} points at \
+ grading time{}",
question.number,
question.points_possible(),
note.map(|n| format!(" — {n}")).unwrap_or_default()
);
}
}
- (common, import.responses)
+
+ (common, result.responses)
}
IngestCommand::Canvas { file, common } => {
let record = load_record(&catalog, &common.assessment)?;
@@ -93,7 +280,7 @@ pub(crate) fn ingest(cli: &Cli, sub: &IngestCommand) -> Result {
println!("wrote {}", path.display());
}
println!(
- "\nNext: coursebank analyze items {}\n coursebank report cohort {}",
+ "\nNext: coursebank analyze items {}\n coursebank report cohort {} --typst",
common.assessment, common.assessment
);
Ok(Outcome::Ok)
@@ -290,7 +477,11 @@ pub(crate) fn calibrate(cli: &Cli, args: &CalibrateArgs) -> Result {
Ok(Outcome::Ok)
}
-/// `report`: write per-student reports or the instructor's cohort item analysis.
+/// `report`: write per-student diagnostics or the class diagnostic.
+///
+/// Markdown is still the default for both, because it diffs and reads in a
+/// terminal. `--typst` adds a document per student, or one for the class, built
+/// from the templates in `templates/` and compiled with `typst compile`.
pub(crate) fn report(cli: &Cli, sub: &ReportCommand) -> Result {
let catalog = load(cli)?;
let store = Store::open(catalog.layout.data())?;
@@ -302,6 +493,12 @@ pub(crate) fn report(cli: &Cli, sub: &ReportCommand) -> Result {
out,
ability,
no_comparison,
+ typst: want_typst,
+ no_markdown,
+ no_questions,
+ no_feedback,
+ template,
+ json,
} => {
let record = load_record(&catalog, id)?;
let set = responses_for(&store, &catalog, &record, false)?;
@@ -311,26 +508,108 @@ pub(crate) fn report(cli: &Cli, sub: &ReportCommand) -> Result {
None
};
let cohort = students::summarize(&set, &catalog.course, Some(&catalog), fit.as_ref());
-
- let opts = report::StudentOptions {
- ability: *ability,
- comparison: !no_comparison,
- ..report::StudentOptions::default()
- };
let dir = out
.clone()
.unwrap_or_else(|| catalog.layout.reports().join(id));
- let written =
- report::write_all_students(&dir, &cohort, &catalog.course, &record, &opts, *html)?;
+
+ warn_about_drift(&catalog, &record, cli.quiet);
+
+ if !no_markdown {
+ let opts = report::StudentOptions {
+ ability: *ability,
+ comparison: !no_comparison,
+ ..report::StudentOptions::default()
+ };
+ let written = report::write_all_students(
+ &dir,
+ &cohort,
+ &catalog.course,
+ &record,
+ &opts,
+ *html,
+ )?;
+ println!(
+ "wrote {} Markdown file(s) for {} student(s) in {}",
+ written.len(),
+ cohort.students.len(),
+ dir.display()
+ );
+ }
+
+ if !want_typst {
+ return Ok(Outcome::Ok);
+ }
+
+ // The item analysis supplies the class rate per question, which is
+ // what makes "you missed q17, and so did most of the class" possible.
+ let analysis =
+ classical::analyze(&set, &Thresholds::default(), Some(&record), Some(&catalog));
+
+ let config = typst::load_config(&catalog.layout)?.resolve(Variant::StudentReport);
+ let meta = typst_diagnostic::Meta::new(&catalog, &record, cohort.students.len());
+ let opts = diagnostic::Options {
+ comparison: !no_comparison,
+ questions: !no_questions,
+ feedback: !no_feedback,
+ ability: *ability,
+ ..diagnostic::Options::default()
+ };
+
+ let mut written = 0usize;
+ let mut used_embedded = false;
+ for summary in &cohort.students {
+ let built =
+ diagnostic::student(summary, &cohort, &catalog, &set, Some(&analysis), &opts);
+ let document = typst_diagnostic::render_student(
+ &catalog.layout,
+ &meta,
+ &built,
+ &config,
+ template.as_deref(),
+ )?;
+ used_embedded |= document.origin == typst::Origin::Embedded;
+ for warning in &document.warnings {
+ eprintln!("warning: {warning}");
+ }
+
+ let stem = typst_diagnostic::student_stem(id, &summary.student_key);
+ let path = dir.join(format!("{stem}.typ"));
+ yaml::write_text(&path, &document.text)?;
+ written += 1;
+
+ if *json {
+ yaml::write_json(&dir.join(format!("{stem}.json")), &built)?;
+ }
+ }
+
println!(
- "wrote {} file(s) for {} student(s) in {}",
- written.len(),
- cohort.students.len(),
- dir.display()
+ "wrote {}",
+ typst_diagnostic::summary(written, cohort.students.len())
);
+ if !cli.quiet {
+ println!(
+ "Compile them all with:\n for f in {}/*.typ; do typst compile \"$f\"; done",
+ dir.display()
+ );
+ if used_embedded {
+ println!(
+ "These used the built-in template. To take over the layout:\n \
+ coursebank template dump --variant student-report"
+ );
+ }
+ }
Ok(Outcome::Ok)
}
- ReportCommand::Cohort { id, html, out } => {
+
+ ReportCommand::Cohort {
+ id,
+ html,
+ out,
+ typst: want_typst,
+ no_markdown,
+ template,
+ json,
+ } => {
let record = load_record(&catalog, id)?;
let set = responses_for(&store, &catalog, &record, false)?;
let analysis =
@@ -338,20 +617,58 @@ pub(crate) fn report(cli: &Cli, sub: &ReportCommand) -> Result {
let fit = irt::fit(&set.matrix(false), &irt::Options::default());
let cohort = students::summarize(&set, &catalog.course, Some(&catalog), Some(&fit));
- let markdown = report::cohort(&analysis, &cohort, &catalog, &record, Some(&fit));
+ warn_about_drift(&catalog, &record, cli.quiet);
+
let path = out
.clone()
.unwrap_or_else(|| catalog.layout.reports().join(format!("{id}-cohort.md")));
- yaml::write_text(&path, &markdown)?;
- println!("wrote {}", path.display());
- if *html {
- let html_path = path.with_extension("html");
- let title = format!("{} — item analysis", record.assessment.title);
- yaml::write_text(&html_path, &report::to_html(&markdown, &title))?;
- println!("wrote {}", html_path.display());
+ if !no_markdown {
+ let markdown = report::cohort(&analysis, &cohort, &catalog, &record, Some(&fit));
+ yaml::write_text(&path, &markdown)?;
+ println!("wrote {}", path.display());
+
+ if *html {
+ let html_path = path.with_extension("html");
+ let title = format!("{} — item analysis", record.assessment.title);
+ yaml::write_text(&html_path, &report::to_html(&markdown, &title))?;
+ println!("wrote {}", html_path.display());
+ }
}
- Ok(Outcome::Ok)
+
+ let built = diagnostic::cohort(&analysis, &cohort, &catalog, &record, &set, Some(&fit));
+
+ for line in typst_diagnostic::headline(&built) {
+ println!("{line}");
+ }
+
+ if *want_typst {
+ let config = typst::load_config(&catalog.layout)?.resolve(Variant::CohortReport);
+ let meta = typst_diagnostic::Meta::new(&catalog, &record, cohort.students.len());
+ let document = typst_diagnostic::render_cohort(
+ &catalog.layout,
+ &meta,
+ &built,
+ &config,
+ template.as_deref(),
+ )?;
+ for warning in &document.warnings {
+ eprintln!("warning: {warning}");
+ }
+ let typst_path = path.with_extension("typ");
+ yaml::write_text(&typst_path, &document.text)?;
+ println!("wrote {} (from {})", typst_path.display(), document.origin);
+
+ if *json {
+ yaml::write_json(&path.with_extension("json"), &built)?;
+ }
+ }
+
+ Ok(if built.revise.is_empty() {
+ Outcome::Ok
+ } else {
+ Outcome::Findings
+ })
}
}
}
@@ -384,3 +701,37 @@ pub(crate) fn data(cli: &Cli) -> Result {
}
Ok(Outcome::Ok)
}
+
+/// Says so when the bank has moved since the exam was sealed.
+///
+/// A report built from a drifted bank is not merely stale: the per-option
+/// feedback it prints was written for options the student may never have seen.
+/// Worth one line at the top of every report run.
+fn warn_about_drift(
+ catalog: &coursebank::catalog::Catalog,
+ record: &coursebank::assessment::AssessmentFile,
+ quiet: bool,
+) {
+ let Ok(Some(file)) = SealFile::find(&catalog.layout, &record.assessment.id) else {
+ return;
+ };
+ let drift = file.verify(catalog, record);
+ let serious: Vec<&seal::Drift> = drift
+ .iter()
+ .filter(|d| d.severity >= Severity::Medium)
+ .collect();
+ if serious.is_empty() {
+ return;
+ }
+ eprintln!(
+ "warning: {} item(s) have changed since this exam was sealed; feedback in these reports \
+ may describe options the students did not see. Run `coursebank seal {} --check`",
+ serious.len(),
+ record.assessment.id
+ );
+ if !quiet {
+ for finding in serious.iter().take(3) {
+ eprintln!(" {}", finding.message);
+ }
+ }
+}
diff --git a/src/commands/export.rs b/src/commands/export.rs
index 1a4f2e1..60450f2 100644
--- a/src/commands/export.rs
+++ b/src/commands/export.rs
@@ -290,7 +290,11 @@ fn pick_practice_variants(names: &[String]) -> Result> {
.collect())
}
-/// Resolves the `--variant` flags, defaulting to every document.
+/// Resolves the `--variant` flags, defaulting to the exam set.
+///
+/// The default is [`typst::Variant::EXAM`] rather than every variant: a
+/// diagnostic is built from responses, not from an assessment record, so
+/// `export typst` has nothing to build one out of.
///
/// # Arguments
///
@@ -306,7 +310,7 @@ fn pick_practice_variants(names: &[String]) -> Result> {
/// Returns [`Error::Usage`] naming the valid tokens.
fn pick_variants(names: &[String]) -> Result> {
if names.is_empty() {
- return Ok(typst::Variant::ALL.to_vec());
+ return Ok(typst::Variant::EXAM.to_vec());
}
let mut wanted = Vec::new();
for name in names {
@@ -384,7 +388,15 @@ pub(crate) fn template(cli: &Cli, sub: &TemplateCommand) -> Result {
force,
stdout,
} => {
- let variants = pick_variants(variant)?;
+ // `export typst` defaults to the exam set, because a report is not
+ // built from an assessment record. Dumping is the opposite case: with
+ // no `--variant` it should hand over every template there is,
+ // including the two reports.
+ let variants = if variant.is_empty() {
+ typst::Variant::ALL.to_vec()
+ } else {
+ pick_variants(variant)?
+ };
if *stdout {
for (index, v) in variants.iter().enumerate() {
diff --git a/src/commands/handlers.rs b/src/commands/handlers.rs
new file mode 100644
index 0000000..72ebe0c
--- /dev/null
+++ b/src/commands/handlers.rs
@@ -0,0 +1,558 @@
+// SPDX-License-Identifier: Prosperity-3.0.0
+// Copyright Scientific Computing Studio
+// Source: https://git.scient.ing/education/coursebank
+
+//! Replacement handlers for `src/commands/analysis.rs`.
+//!
+//! This file is not a module of its own: `seal` is new, and `ingest` and `report`
+//! replace the functions of the same name in `commands/analysis.rs`. Paste them
+//! in there, add the imports listed at the top, and delete this file. It is kept
+//! separate here only so the diff against the existing file is obvious.
+//!
+//! Imports `commands/analysis.rs` needs on top of what it already has:
+//!
+//! ```ignore
+//! use std::collections::BTreeMap;
+//!
+//! use coursebank::decode::Numbering;
+//! use coursebank::diagnostic;
+//! use coursebank::intake::{self, Source};
+//! use coursebank::seal::{self, SealFile};
+//! use coursebank::typst::{self, Variant};
+//! use coursebank::typst::diagnostic as typst_diagnostic;
+//!
+//! use crate::cli::SealArgs;
+//! ```
+
+use std::collections::BTreeMap;
+
+use coursebank::canvas;
+use coursebank::catalog::Severity;
+use coursebank::classical::{self, Thresholds};
+use coursebank::decode::Numbering;
+use coursebank::diagnostic;
+use coursebank::error::{Error, Result};
+use coursebank::intake::{self, Source};
+use coursebank::irt;
+use coursebank::report;
+use coursebank::seal::{self, SealFile};
+use coursebank::store::Store;
+use coursebank::students;
+use coursebank::typst::diagnostic as typst_diagnostic;
+use coursebank::typst::{self, Variant};
+use coursebank::yaml;
+
+use crate::cli::{Cli, IngestCommand, ReportCommand, SealArgs};
+use crate::commands::Outcome;
+use crate::helpers::{context, load, load_record, read_salt, responses_for};
+
+/// `seal`: freeze what was administered, or check the freeze.
+///
+/// Write the seal after exporting the papers and before printing them. It records
+/// the stem and options of every item, the printed-letter map for every form, and
+/// digests over both, so that the question "is the bank still what the students
+/// saw?" has an answer six months from now.
+pub(crate) fn seal(cli: &Cli, args: &SealArgs) -> Result {
+ let catalog = load(cli)?;
+ let record = load_record(&catalog, &args.id)?;
+ let path = args
+ .out
+ .clone()
+ .unwrap_or_else(|| SealFile::path(&catalog.layout, &record.assessment.id));
+
+ if args.check {
+ let existing = SealFile::load(&path).map_err(|_| {
+ Error::usage(format!(
+ "no seal at {}. Write one with `coursebank seal {}`",
+ path.display(),
+ args.id
+ ))
+ })?;
+
+ let drift = existing.verify(&catalog, &record);
+ if drift.is_empty() {
+ println!(
+ "{} matches the seal written {} ({})",
+ args.id,
+ existing.seal.sealed_on,
+ seal::short(&existing.seal.digest)
+ );
+ return Ok(Outcome::Ok);
+ }
+
+ println!(
+ "{} finding(s) against the seal written {}:\n",
+ drift.len(),
+ existing.seal.sealed_on
+ );
+ for finding in &drift {
+ println!(" {:<6} {}", finding.severity.label(), finding.message);
+ }
+ if drift.iter().any(|d| d.is_blocking()) {
+ println!(
+ "\nAnalysis that pools this administration with another is comparing two \
+ different questions. Per-option feedback in student reports may name the wrong \
+ option."
+ );
+ }
+ return Ok(Outcome::Findings);
+ }
+
+ if path.exists() && !args.force {
+ return Err(Error::usage(format!(
+ "{} already exists. A seal is meant to be written once, before the exam is printed; \
+ pass --force only if you are re-sealing an assessment that was never administered",
+ path.display()
+ )));
+ }
+
+ let opts = seal::Options {
+ content: !args.no_content,
+ forms: args.form.clone(),
+ };
+ let file = seal::build(&catalog, &record, &opts)?;
+ file.save(&path)?;
+
+ println!(
+ "wrote {} ({} item(s), {} form(s), {})",
+ path.display(),
+ file.items.len(),
+ file.forms.len(),
+ seal::short(&file.seal.digest)
+ );
+ let mut advisories = Vec::new();
+ for form in &file.forms {
+ let permuted = form
+ .questions
+ .iter()
+ .filter(|q| q.options.iter().any(|o| o.printed != o.canonical))
+ .count();
+ println!(
+ " form {}: {} question(s), {} with permuted options, {}",
+ form.id,
+ form.questions.len(),
+ permuted,
+ seal::short(&form.digest)
+ );
+ advisories.extend(seal::balance(form).notes());
+ }
+
+ // The last moment before printing is the only cheap moment to notice that a
+ // shuffle produced a sequence a student will read as a mistake.
+ if !advisories.is_empty() {
+ println!();
+ for note in &advisories {
+ println!("! {note}");
+ }
+ println!(
+ "\nRe-seed a form by editing its `seed:` in the record, then re-export and re-seal \
+ with --force. Nothing else has to change."
+ );
+ }
+
+ if !cli.quiet {
+ println!(
+ "\nCommit this file. Check it any time with: coursebank seal {} --check",
+ args.id
+ );
+ }
+ Ok(Outcome::Ok)
+}
+
+/// `ingest`: read grading exports into the response store.
+///
+/// The Gradescope path takes one directory per form and merges them into a single
+/// administration, translating each form's printed letters into the bank's
+/// lettering on the way in. See [`coursebank::intake`].
+pub(crate) fn ingest(cli: &Cli, sub: &IngestCommand) -> Result {
+ let catalog = load(cli)?;
+
+ let (common, mut set) = match sub {
+ IngestCommand::Gradescope {
+ sources,
+ common,
+ allow_mismatch,
+ recorded_numbers,
+ } => {
+ let record = load_record(&catalog, &common.assessment)?;
+ let sealed = SealFile::find(&catalog.layout, &record.assessment.id)?;
+
+ if let Some(file) = &sealed {
+ let drift = file.verify(&catalog, &record);
+ let blocking: Vec<&seal::Drift> =
+ drift.iter().filter(|d| d.is_blocking()).collect();
+ for finding in &drift {
+ println!("! {} {}", finding.severity.label(), finding.message);
+ }
+ if !blocking.is_empty() {
+ println!(
+ "\nThe seal still describes the papers the students held, so ingest will \
+ use it. The bank has moved since; `coursebank seal {} --check` lists \
+ what.",
+ common.assessment
+ );
+ }
+ } else if !cli.quiet {
+ println!(
+ "! no seal for {}; the option maps will be derived from the record as it \
+ stands today. Write one next time with `coursebank seal {}` before printing",
+ common.assessment, common.assessment
+ );
+ }
+
+ let mut parsed = Vec::new();
+ for text in sources {
+ parsed.push(Source::parse(text, common.form.as_deref())?);
+ }
+ for source in &parsed {
+ if !intake::looks_like_export(&source.dir) {
+ return Err(Error::usage(format!(
+ "{} holds no files named `1.csv` … `N.csv`. Gradescope writes one file \
+ per question; point at the directory those were unzipped into",
+ source.dir.display()
+ )));
+ }
+ }
+
+ let date = match &common.date {
+ Some(text) => Some(text.parse()?),
+ None => None,
+ };
+ let opts = intake::Options {
+ date,
+ numbering: if *recorded_numbers {
+ Numbering::Recorded
+ } else {
+ Numbering::Printed
+ },
+ strict: !*allow_mismatch,
+ };
+
+ let result = intake::run(&catalog, &record, sealed.as_ref(), &parsed, &opts)?;
+
+ for form in &result.forms {
+ println!("{}", intake::describe(form));
+ }
+
+ // Grading-time partial credit is an ambiguity signal worth surfacing
+ // while the exam is fresh, and it is per form because a regrade
+ // applied to one form and not the other is its own problem.
+ for (form, question) in result.questions() {
+ for (letter, value, note) in question.partial_credit() {
+ println!(
+ "! form {form} q{}: option {letter} earned {value} of {} points at \
+ grading time{}",
+ question.number,
+ question.points_possible(),
+ note.map(|n| format!(" — {n}")).unwrap_or_default()
+ );
+ }
+ }
+
+ (common, result.responses)
+ }
+ IngestCommand::Canvas { file, common } => {
+ let record = load_record(&catalog, &common.assessment)?;
+ let ctx = context(&catalog, &record, common)?;
+ let set = canvas::ingest(file, &ctx, Some(&record), Some(&catalog))?;
+ (common, set)
+ }
+ };
+
+ let record = load_record(&catalog, &common.assessment)?;
+ set.enrich(&record, Some(&catalog));
+
+ if common.pseudonymize {
+ let salt = read_salt(common.salt_file.as_deref())?;
+ set.pseudonymize(&salt);
+ println!("identifiers replaced with keyed pseudonyms");
+ }
+
+ for warning in &set.warnings {
+ println!("! {warning}");
+ }
+
+ println!(
+ "\n{} response(s): {} student(s) x {} item(s)",
+ set.rows.len(),
+ set.students().len(),
+ set.all_items().len()
+ );
+
+ if common.dry_run {
+ println!("(dry run, nothing written)");
+ return Ok(Outcome::Ok);
+ }
+
+ let mut store = Store::open(catalog.layout.data())?;
+ if let Some(format) = common.format {
+ store = store.with_format(format.as_format())?;
+ }
+ for path in store.write(&set)? {
+ println!("wrote {}", path.display());
+ }
+ println!(
+ "\nNext: coursebank analyze items {}\n coursebank report cohort {} --typst",
+ common.assessment, common.assessment
+ );
+ Ok(Outcome::Ok)
+}
+
+/// `report`: write per-student diagnostics or the class diagnostic.
+///
+/// Markdown is still the default for both, because it diffs and reads in a
+/// terminal. `--typst` adds a document per student, or one for the class, built
+/// from the templates in `templates/` and compiled with `typst compile`.
+pub(crate) fn report(cli: &Cli, sub: &ReportCommand) -> Result {
+ let catalog = load(cli)?;
+ let store = Store::open(catalog.layout.data())?;
+
+ match sub {
+ ReportCommand::Students {
+ id,
+ html,
+ out,
+ ability,
+ no_comparison,
+ typst: want_typst,
+ no_markdown,
+ no_questions,
+ no_feedback,
+ template,
+ json,
+ } => {
+ let record = load_record(&catalog, id)?;
+ let set = responses_for(&store, &catalog, &record, false)?;
+ let fit = if *ability {
+ Some(irt::fit(&set.matrix(false), &irt::Options::default()))
+ } else {
+ None
+ };
+ let cohort = students::summarize(&set, &catalog.course, Some(&catalog), fit.as_ref());
+ let dir = out
+ .clone()
+ .unwrap_or_else(|| catalog.layout.reports().join(id));
+
+ warn_about_drift(&catalog, &record, cli.quiet);
+
+ if !no_markdown {
+ let opts = report::StudentOptions {
+ ability: *ability,
+ comparison: !no_comparison,
+ ..report::StudentOptions::default()
+ };
+ let written = report::write_all_students(
+ &dir,
+ &cohort,
+ &catalog.course,
+ &record,
+ &opts,
+ *html,
+ )?;
+ println!(
+ "wrote {} Markdown file(s) for {} student(s) in {}",
+ written.len(),
+ cohort.students.len(),
+ dir.display()
+ );
+ }
+
+ if !want_typst {
+ return Ok(Outcome::Ok);
+ }
+
+ // The item analysis supplies the class rate per question, which is
+ // what makes "you missed q17, and so did most of the class" possible.
+ let analysis =
+ classical::analyze(&set, &Thresholds::default(), Some(&record), Some(&catalog));
+
+ let config = typst::load_config(&catalog.layout)?.resolve(Variant::StudentReport);
+ let meta = typst_diagnostic::Meta::new(&catalog, &record, cohort.students.len());
+ let opts = diagnostic::Options {
+ comparison: !no_comparison,
+ questions: !no_questions,
+ feedback: !no_feedback,
+ ability: *ability,
+ ..diagnostic::Options::default()
+ };
+
+ let mut written = 0usize;
+ let mut used_embedded = false;
+ for summary in &cohort.students {
+ let built = diagnostic::student(
+ summary,
+ &cohort,
+ &catalog,
+ &set,
+ Some(&analysis),
+ &opts,
+ );
+ let document = typst_diagnostic::render_student(
+ &catalog.layout,
+ &meta,
+ &built,
+ &config,
+ template.as_deref(),
+ )?;
+ used_embedded |= document.origin == typst::Origin::Embedded;
+ for warning in &document.warnings {
+ eprintln!("warning: {warning}");
+ }
+
+ let stem = typst_diagnostic::student_stem(id, &summary.student_key);
+ let path = dir.join(format!("{stem}.typ"));
+ yaml::write_text(&path, &document.text)?;
+ written += 1;
+
+ if *json {
+ yaml::write_json(&dir.join(format!("{stem}.json")), &built)?;
+ }
+ }
+
+ println!(
+ "wrote {}",
+ typst_diagnostic::summary(written, cohort.students.len())
+ );
+ if !cli.quiet {
+ println!(
+ "Compile them all with:\n for f in {}/*.typ; do typst compile \"$f\"; done",
+ dir.display()
+ );
+ if used_embedded {
+ println!(
+ "These used the built-in template. To take over the layout:\n \
+ coursebank template dump --variant student-report"
+ );
+ }
+ }
+ Ok(Outcome::Ok)
+ }
+
+ ReportCommand::Cohort {
+ id,
+ html,
+ out,
+ typst: want_typst,
+ no_markdown,
+ template,
+ json,
+ } => {
+ let record = load_record(&catalog, id)?;
+ let set = responses_for(&store, &catalog, &record, false)?;
+ let analysis =
+ classical::analyze(&set, &Thresholds::default(), Some(&record), Some(&catalog));
+ let fit = irt::fit(&set.matrix(false), &irt::Options::default());
+ let cohort = students::summarize(&set, &catalog.course, Some(&catalog), Some(&fit));
+
+ warn_about_drift(&catalog, &record, cli.quiet);
+
+ let path = out
+ .clone()
+ .unwrap_or_else(|| catalog.layout.reports().join(format!("{id}-cohort.md")));
+
+ if !no_markdown {
+ let markdown = report::cohort(&analysis, &cohort, &catalog, &record, Some(&fit));
+ yaml::write_text(&path, &markdown)?;
+ println!("wrote {}", path.display());
+
+ if *html {
+ let html_path = path.with_extension("html");
+ let title = format!("{} — item analysis", record.assessment.title);
+ yaml::write_text(&html_path, &report::to_html(&markdown, &title))?;
+ println!("wrote {}", html_path.display());
+ }
+ }
+
+ let built = diagnostic::cohort(
+ &analysis,
+ &cohort,
+ &catalog,
+ &record,
+ &set,
+ Some(&fit),
+ );
+
+ for line in typst_diagnostic::headline(&built) {
+ println!("{line}");
+ }
+
+ if *want_typst {
+ let config = typst::load_config(&catalog.layout)?.resolve(Variant::CohortReport);
+ let meta = typst_diagnostic::Meta::new(&catalog, &record, cohort.students.len());
+ let document = typst_diagnostic::render_cohort(
+ &catalog.layout,
+ &meta,
+ &built,
+ &config,
+ template.as_deref(),
+ )?;
+ for warning in &document.warnings {
+ eprintln!("warning: {warning}");
+ }
+ let typst_path = path.with_extension("typ");
+ yaml::write_text(&typst_path, &document.text)?;
+ println!("wrote {} (from {})", typst_path.display(), document.origin);
+
+ if *json {
+ yaml::write_json(&path.with_extension("json"), &built)?;
+ }
+ }
+
+ Ok(if built.revise.is_empty() {
+ Outcome::Ok
+ } else {
+ Outcome::Findings
+ })
+ }
+ }
+}
+
+/// Says so when the bank has moved since the exam was sealed.
+///
+/// A report built from a drifted bank is not merely stale: the per-option
+/// feedback it prints was written for options the student may never have seen.
+/// Worth one line at the top of every report run.
+fn warn_about_drift(
+ catalog: &coursebank::catalog::Catalog,
+ record: &coursebank::assessment::AssessmentFile,
+ quiet: bool,
+) {
+ let Ok(Some(file)) = SealFile::find(&catalog.layout, &record.assessment.id) else {
+ return;
+ };
+ let drift = file.verify(catalog, record);
+ let serious: Vec<&seal::Drift> = drift
+ .iter()
+ .filter(|d| d.severity >= Severity::Medium)
+ .collect();
+ if serious.is_empty() {
+ return;
+ }
+ eprintln!(
+ "warning: {} item(s) have changed since this exam was sealed; feedback in these reports \
+ may describe options the students did not see. Run `coursebank seal {} --check`",
+ serious.len(),
+ record.assessment.id
+ );
+ if !quiet {
+ for finding in serious.iter().take(3) {
+ eprintln!(" {}", finding.message);
+ }
+ }
+}
+
+/// Counts how many students each form was given to, for the ingest summary.
+///
+/// Kept here rather than in the library because it exists to print a line.
+#[allow(dead_code)]
+fn students_per_form(set: &coursebank::responses::ResponseSet) -> BTreeMap {
+ let mut seen: BTreeMap> = BTreeMap::new();
+ for row in &set.rows {
+ if let Some(form) = row.form.as_deref() {
+ seen.entry(form.to_string())
+ .or_default()
+ .insert(row.student_key.as_str());
+ }
+ }
+ seen.into_iter().map(|(k, v)| (k, v.len())).collect()
+}
diff --git a/src/data.rs b/src/data.rs
index 1fb0417..f36152c 100644
--- a/src/data.rs
+++ b/src/data.rs
@@ -26,7 +26,9 @@
//! `--no-default-features` a one-file change rather than a refactor.
pub mod canvas;
+pub mod decode;
pub mod gradescope;
+pub mod intake;
pub mod responses;
pub mod store;
pub mod store_parquet;
diff --git a/src/data/canvas.rs b/src/data/canvas.rs
index 16f9982..0d9e873 100644
--- a/src/data/canvas.rs
+++ b/src/data/canvas.rs
@@ -325,6 +325,10 @@ pub fn ingest(
assessment_id: ctx.assessment_id.clone(),
date: ctx.date,
form: ctx.form.clone(),
+ // Canvas numbers questions as the record does, and its exports
+ // carry no printed order, so there is no printed position to
+ // record and no letter map to decode against.
+ form_position: None,
student_key: student_key.clone(),
sid: sid.clone(),
name: name.clone(),
@@ -334,7 +338,9 @@ pub fn ingest(
item_ref,
item_version: None,
selected,
+ selected_source: Vec::new(),
eliminated: Vec::new(),
+ eliminated_source: Vec::new(),
correct,
credit,
points_possible: points,
diff --git a/src/data/decode.rs b/src/data/decode.rs
new file mode 100644
index 0000000..3f51421
--- /dev/null
+++ b/src/data/decode.rs
@@ -0,0 +1,803 @@
+// SPDX-License-Identifier: Prosperity-3.0.0
+// Copyright Scientific Computing Studio
+// Source: https://git.scient.ing/education/coursebank
+
+//! Turning what a student marked into what a student chose.
+//!
+//! A grading export speaks in positions and printed letters. Question 14 is the
+//! fourteenth thing on the page; option C is the third bubble. An item bank speaks
+//! in ids and its own lettering. When forms shuffle, those two vocabularies
+//! disagree, and every analysis downstream of the disagreement is wrong in a way
+//! that looks right:
+//!
+//! * Pooled distractor statistics add form A's option C to form B's option C,
+//! which are different sentences. The resulting table is noise with the shape of
+//! data.
+//! * A student report looks up the misconception recorded on option C and shows it
+//! to a student who chose a different option. The feedback is confident,
+//! specific, and about the wrong thing.
+//! * Any `credit_overrides` written in the record's lettering are applied to
+//! whoever happened to mark that letter on their form.
+//!
+//! None of these fail loudly. That is the argument for doing the translation once,
+//! at ingest, and storing both sides of it.
+//!
+//! # What a decoder knows
+//!
+//! For one form: which recorded question number sits at each printed position,
+//! which bank letter each printed letter stands for, and which printed letters are
+//! keyed. It is built from a [`SealFile`] when one exists, and derived from the
+//! record and the form seed when one does not. Sealed is better, and not only
+//! because it is faster: a derived decoder describes the form the bank *would*
+//! print today, while a sealed one describes the form that was actually printed.
+//!
+//! # Catching a swapped directory
+//!
+//! Gradescope's point-value row reveals which printed letter earned full credit on
+//! every question. A decoder knows what that letter should be. Comparing them
+//! across a whole directory is close to a proof of which form the directory holds:
+//! agreement is near total for the right form and near chance for the wrong one.
+//! [`identify_form`] uses that to refuse an ingest that names form A over a
+//! directory of form B papers, which is otherwise a mistake nobody catches until
+//! the item statistics look strange three weeks later.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use crate::assessment::{AssessmentFile, Form};
+use crate::catalog::Catalog;
+use crate::error::{Error, Result};
+use crate::gradescope::Question;
+use crate::responses::ResponseSet;
+use crate::seal::{SealFile, printed_letter};
+use crate::select;
+
+/// Where a decoder's mapping came from.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Provenance {
+ /// Read from a seal written before administration. Authoritative.
+ Seal,
+ /// Derived from the assessment record and the form seed, as of now.
+ Derived,
+}
+
+impl Provenance {
+ /// A short label for output.
+ pub fn label(self) -> &'static str {
+ match self {
+ Provenance::Seal => "seal",
+ Provenance::Derived => "derived from the record",
+ }
+ }
+}
+
+/// How a grading export numbers its questions.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Numbering {
+ /// The export counts printed positions, which is what Gradescope's `N.csv`
+ /// file names mean. Positions are translated to recorded numbers.
+ Printed,
+ /// The export already carries recorded question numbers, so numbers pass
+ /// through untouched.
+ Recorded,
+}
+
+/// One question's mapping on one form.
+#[derive(Debug, Clone)]
+pub struct QuestionMap {
+ /// Printed position on the page, counting from 1.
+ pub position: u32,
+ /// The recorded question number, the join key to the record and the store.
+ pub number: u32,
+ /// The item's global id.
+ pub item: String,
+ /// Keyed letters as printed on this form.
+ pub printed_key: Vec,
+ /// Keyed letters in the bank's own lettering.
+ pub canonical_key: Vec,
+ /// Printed letter to bank letter.
+ pub to_canonical: BTreeMap,
+ /// Bank letter to printed letter.
+ pub to_printed: BTreeMap,
+}
+
+impl QuestionMap {
+ /// The bank letter a printed letter stands for.
+ ///
+ /// # Arguments
+ ///
+ /// * `printed` - the letter as the student saw it.
+ ///
+ /// # Returns
+ ///
+ /// The bank letter, or `None` when the printed letter is not one of this
+ /// question's options.
+ pub fn canonical(&self, printed: &str) -> Option<&str> {
+ self.to_canonical
+ .get(&printed.trim().to_ascii_uppercase())
+ .map(|s| s.as_str())
+ }
+
+ /// How many options this question has.
+ pub fn n_options(&self) -> usize {
+ self.to_canonical.len()
+ }
+
+ /// Whether this question's options were actually permuted.
+ pub fn is_permuted(&self) -> bool {
+ self.to_canonical.iter().any(|(k, v)| k != v)
+ }
+}
+
+/// One form's full mapping.
+#[derive(Debug, Clone)]
+pub struct FormDecoder {
+ /// The form id.
+ pub form: String,
+ /// Where the mapping came from.
+ pub provenance: Provenance,
+ /// Questions by printed position.
+ by_position: BTreeMap,
+ /// Questions by recorded number.
+ by_number: BTreeMap,
+}
+
+impl FormDecoder {
+ /// Builds a decoder from the question maps.
+ fn assemble(form: String, provenance: Provenance, maps: Vec) -> FormDecoder {
+ let by_position = maps.iter().map(|m| (m.position, m.clone())).collect();
+ let by_number = maps.into_iter().map(|m| (m.number, m)).collect();
+ FormDecoder {
+ form,
+ provenance,
+ by_position,
+ by_number,
+ }
+ }
+
+ /// Reads one form's mapping out of a seal.
+ ///
+ /// # Arguments
+ ///
+ /// * `seal` - the seal.
+ /// * `form_id` - the form to decode, matched case-insensitively.
+ ///
+ /// # Returns
+ ///
+ /// The decoder.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Usage`] when the seal does not cover that form.
+ pub fn from_seal(seal: &SealFile, form_id: &str) -> Result {
+ let form = seal.form(form_id).ok_or_else(|| {
+ Error::usage(format!(
+ "the seal for `{}` does not cover form `{form_id}`; it covers {}",
+ seal.seal.assessment,
+ seal.forms
+ .iter()
+ .map(|f| f.id.as_str())
+ .collect::>()
+ .join(", ")
+ ))
+ })?;
+
+ let maps = form
+ .questions
+ .iter()
+ .map(|q| {
+ let mut to_canonical = BTreeMap::new();
+ let mut to_printed = BTreeMap::new();
+ for map in &q.options {
+ to_canonical.insert(map.printed.clone(), map.canonical.clone());
+ to_printed.insert(map.canonical.clone(), map.printed.clone());
+ }
+ let canonical_key: Vec = q
+ .printed_key
+ .iter()
+ .filter_map(|p| to_canonical.get(p).cloned())
+ .collect();
+ QuestionMap {
+ position: q.position,
+ number: q.number,
+ item: q.item.clone(),
+ printed_key: q.printed_key.clone(),
+ canonical_key,
+ to_canonical,
+ to_printed,
+ }
+ })
+ .collect();
+
+ Ok(FormDecoder::assemble(
+ form.id.clone(),
+ Provenance::Seal,
+ maps,
+ ))
+ }
+
+ /// Derives one form's mapping from the record and the bank.
+ ///
+ /// Uses the same two functions every export calls, so a derived decoder and a
+ /// freshly exported paper agree by construction.
+ ///
+ /// # Arguments
+ ///
+ /// * `catalog` - the loaded course.
+ /// * `record` - the assessment record.
+ /// * `form` - the form.
+ ///
+ /// # Returns
+ ///
+ /// The decoder.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Unresolved`] when a placement references a missing item.
+ pub fn derive(catalog: &Catalog, record: &AssessmentFile, form: &Form) -> Result {
+ let printed: Vec<_> = select::layout(record, form)
+ .into_iter()
+ .filter(|p| !p.dropped)
+ .collect();
+
+ let mut maps = Vec::with_capacity(printed.len());
+ for (index, placement) in printed.iter().enumerate() {
+ let entry = catalog.require(&placement.item)?;
+ let item = &entry.item;
+ let order = select::option_order(form, &placement.item, item.options.len());
+
+ let canonical_key: BTreeSet = if placement.key.is_empty() {
+ item.key_letters().into_iter().collect()
+ } else {
+ placement.key.iter().cloned().collect()
+ };
+
+ let mut to_canonical = BTreeMap::new();
+ let mut to_printed = BTreeMap::new();
+ let mut printed_key = Vec::new();
+ for (position, source_index) in order.iter().enumerate() {
+ let canonical = item
+ .options
+ .get(*source_index)
+ .map(|c| c.id.clone())
+ .unwrap_or_else(|| printed_letter(*source_index));
+ let label = printed_letter(position);
+ if canonical_key.contains(&canonical) {
+ printed_key.push(label.clone());
+ }
+ to_canonical.insert(label.clone(), canonical.clone());
+ to_printed.insert(canonical, label);
+ }
+
+ maps.push(QuestionMap {
+ position: index as u32 + 1,
+ number: placement.number,
+ item: placement.item.clone(),
+ printed_key,
+ canonical_key: canonical_key.into_iter().collect(),
+ to_canonical,
+ to_printed,
+ });
+ }
+
+ Ok(FormDecoder::assemble(
+ form.id.clone(),
+ Provenance::Derived,
+ maps,
+ ))
+ }
+
+ /// Builds a decoder, preferring the seal.
+ ///
+ /// # Arguments
+ ///
+ /// * `seal` - the seal, when one has been written.
+ /// * `catalog` - the loaded course.
+ /// * `record` - the assessment record.
+ /// * `form` - the form.
+ ///
+ /// # Returns
+ ///
+ /// The decoder.
+ ///
+ /// # Errors
+ ///
+ /// As [`FormDecoder::from_seal`] and [`FormDecoder::derive`]. A seal that does
+ /// not cover the requested form falls back to deriving rather than failing,
+ /// since a form added after sealing is a real situation.
+ pub fn resolve(
+ seal: Option<&SealFile>,
+ catalog: &Catalog,
+ record: &AssessmentFile,
+ form: &Form,
+ ) -> Result {
+ if let Some(seal) = seal {
+ if seal.form(&form.id).is_some() {
+ return FormDecoder::from_seal(seal, &form.id);
+ }
+ }
+ FormDecoder::derive(catalog, record, form)
+ }
+
+ /// The question at a printed position.
+ ///
+ /// # Arguments
+ ///
+ /// * `position` - the printed position, counting from 1.
+ pub fn at_position(&self, position: u32) -> Option<&QuestionMap> {
+ self.by_position.get(&position)
+ }
+
+ /// The question with a recorded number.
+ ///
+ /// # Arguments
+ ///
+ /// * `number` - the recorded number.
+ pub fn at_number(&self, number: u32) -> Option<&QuestionMap> {
+ self.by_number.get(&number)
+ }
+
+ /// The question an export's numbering refers to.
+ ///
+ /// # Arguments
+ ///
+ /// * `n` - the number as the export gives it.
+ /// * `numbering` - how the export numbers questions.
+ pub fn lookup(&self, n: u32, numbering: Numbering) -> Option<&QuestionMap> {
+ match numbering {
+ Numbering::Printed => self.at_position(n),
+ Numbering::Recorded => self.at_number(n),
+ }
+ }
+
+ /// How many questions this form prints.
+ pub fn len(&self) -> usize {
+ self.by_position.len()
+ }
+
+ /// Whether the form prints nothing, which means the record is empty.
+ pub fn is_empty(&self) -> bool {
+ self.by_position.is_empty()
+ }
+
+ /// Whether any question on this form has permuted options.
+ ///
+ /// Used to decide whether to say anything about translation at all: on an
+ /// unshuffled form the whole mechanism is an identity map and mentioning it
+ /// is noise.
+ pub fn is_permuted(&self) -> bool {
+ self.by_position.values().any(|q| q.is_permuted())
+ }
+
+ /// Whether printed positions and recorded numbers disagree anywhere.
+ ///
+ /// True when items were shuffled, and also when a bonus item sits mid-record,
+ /// since the layout moves bonus items to the end of the paper.
+ pub fn is_renumbered(&self) -> bool {
+ self.by_position.values().any(|q| q.position != q.number)
+ }
+}
+
+/// What a directory of graded questions says about which form it holds.
+#[derive(Debug, Clone)]
+pub struct FormFit {
+ /// The form id.
+ pub form: String,
+ /// Questions whose graded key matched this form's printed key.
+ pub matched: usize,
+ /// Questions that could be compared at all.
+ pub compared: usize,
+ /// Question positions where the graded key disagreed.
+ pub mismatches: Vec,
+}
+
+impl FormFit {
+ /// The share of comparable questions that agreed.
+ pub fn rate(&self) -> f64 {
+ if self.compared == 0 {
+ 0.0
+ } else {
+ self.matched as f64 / self.compared as f64
+ }
+ }
+
+ /// Whether the fit is good enough to proceed without a warning.
+ ///
+ /// The threshold is high on purpose. A correctly matched directory agrees on
+ /// every question; anything less than total agreement is either a regrade that
+ /// moved a key or the wrong directory, and both are worth a sentence.
+ pub fn is_convincing(&self) -> bool {
+ self.compared > 0 && self.matched == self.compared
+ }
+}
+
+/// Compares a parsed Gradescope directory against one form's expected keys.
+///
+/// # Arguments
+///
+/// * `questions` - the parsed question files.
+/// * `decoder` - the form to test against.
+/// * `numbering` - how the export numbers questions.
+///
+/// # Returns
+///
+/// The fit.
+pub fn fit_form(questions: &[Question], decoder: &FormDecoder, numbering: Numbering) -> FormFit {
+ let mut matched = 0usize;
+ let mut compared = 0usize;
+ let mut mismatches = Vec::new();
+
+ for question in questions {
+ let Some(map) = decoder.lookup(question.number, numbering) else {
+ continue;
+ };
+ let graded: BTreeSet = question.keyed().into_iter().collect();
+ if graded.is_empty() {
+ continue;
+ }
+ let expected: BTreeSet = map.printed_key.iter().cloned().collect();
+ if expected.is_empty() {
+ continue;
+ }
+ compared += 1;
+ if graded == expected {
+ matched += 1;
+ } else {
+ mismatches.push(question.number);
+ }
+ }
+
+ FormFit {
+ form: decoder.form.clone(),
+ matched,
+ compared,
+ mismatches,
+ }
+}
+
+/// Ranks every candidate form against a directory.
+///
+/// # Arguments
+///
+/// * `questions` - the parsed question files.
+/// * `decoders` - one decoder per declared form.
+/// * `numbering` - how the export numbers questions.
+///
+/// # Returns
+///
+/// The fits, best first.
+pub fn identify_form(
+ questions: &[Question],
+ decoders: &[FormDecoder],
+ numbering: Numbering,
+) -> Vec {
+ let mut fits: Vec = decoders
+ .iter()
+ .map(|d| fit_form(questions, d, numbering))
+ .collect();
+ fits.sort_by(|a, b| {
+ b.rate()
+ .partial_cmp(&a.rate())
+ .unwrap_or(std::cmp::Ordering::Equal)
+ .then_with(|| a.form.cmp(&b.form))
+ });
+ fits
+}
+
+/// Explains a fit in a sentence, or says nothing when the fit is perfect.
+///
+/// # Arguments
+///
+/// * `claimed` - the form the directory was ingested as.
+/// * `fits` - every form's fit, best first.
+///
+/// # Returns
+///
+/// A warning, or `None`.
+pub fn form_warning(claimed: &str, fits: &[FormFit]) -> Option {
+ let mine = fits.iter().find(|f| f.form.eq_ignore_ascii_case(claimed))?;
+ if mine.is_convincing() {
+ return None;
+ }
+ if mine.compared == 0 {
+ return Some(format!(
+ "form {claimed}: the export carries no point values, so the graded keys could not be \
+ checked against the form. Nothing verified this directory is form {claimed}"
+ ));
+ }
+
+ let better = fits
+ .iter()
+ .find(|f| !f.form.eq_ignore_ascii_case(claimed) && f.rate() > mine.rate());
+
+ let head = format!(
+ "form {claimed}: the graded key matches this form on {} of {} question(s)",
+ mine.matched, mine.compared
+ );
+ let where_ = if mine.mismatches.is_empty() {
+ String::new()
+ } else {
+ let list: Vec = mine
+ .mismatches
+ .iter()
+ .take(8)
+ .map(|n| n.to_string())
+ .collect();
+ format!(
+ " (q{}{})",
+ list.join(", q"),
+ if mine.mismatches.len() > 8 {
+ ", …"
+ } else {
+ ""
+ }
+ )
+ };
+ match better {
+ Some(other) => Some(format!(
+ "{head}{where_}, but matches form {} on {} of {}. This directory is almost certainly \
+ form {}, not form {claimed}",
+ other.form, other.matched, other.compared, other.form
+ )),
+ None => Some(format!(
+ "{head}{where_}. Either those questions were regraded after printing, or the form is \
+ not the one named"
+ )),
+ }
+}
+
+/// Translates a form's responses into the bank's vocabulary.
+///
+/// Rewrites, for every row whose `form` matches this decoder:
+///
+/// * `item_number`, from printed position to recorded number, when the export
+/// numbers by position;
+/// * `form_position`, recording where the question sat on the page;
+/// * `selected_source` and `eliminated_source`, the bank letters for what was
+/// marked. The printed letters stay in `selected` and `eliminated`, because what
+/// a student physically marked is the fact and the translation is the
+/// interpretation.
+///
+/// Correctness and credit are untouched. Both come from the grading platform,
+/// which scored the paper the student actually held, and are already right.
+///
+/// # Arguments
+///
+/// * `set` - the responses to translate, in place.
+/// * `decoder` - the form's mapping.
+/// * `numbering` - how the export numbered questions.
+///
+/// # Returns
+///
+/// Warnings for anything that could not be translated.
+pub fn apply(set: &mut ResponseSet, decoder: &FormDecoder, numbering: Numbering) -> Vec {
+ let mut warnings = Vec::new();
+ let mut unmapped_positions: BTreeSet = BTreeSet::new();
+ let mut unmapped_letters: BTreeSet = BTreeSet::new();
+ let mut translated = 0usize;
+
+ for row in &mut set.rows {
+ let belongs = row
+ .form
+ .as_deref()
+ .map(|f| f.eq_ignore_ascii_case(&decoder.form))
+ .unwrap_or(false);
+ if !belongs {
+ continue;
+ }
+
+ let Some(map) = decoder.lookup(row.item_number, numbering) else {
+ unmapped_positions.insert(row.item_number);
+ continue;
+ };
+
+ row.form_position = Some(map.position);
+ row.item_number = map.number;
+
+ let mut convert = |letters: &[String]| -> Vec {
+ let mut out = Vec::with_capacity(letters.len());
+ for letter in letters {
+ match map.canonical(letter) {
+ Some(canonical) => out.push(canonical.to_string()),
+ None => {
+ unmapped_letters.insert(format!("q{} {}", map.number, letter));
+ }
+ }
+ }
+ out.sort();
+ out
+ };
+
+ row.selected_source = convert(&row.selected);
+ row.eliminated_source = convert(&row.eliminated);
+ translated += 1;
+ }
+
+ if !unmapped_positions.is_empty() {
+ let list: Vec = unmapped_positions.iter().map(|n| n.to_string()).collect();
+ warnings.push(format!(
+ "form {}: question(s) {} are in the export but not on this form; they were left \
+ untranslated",
+ decoder.form,
+ list.join(", ")
+ ));
+ }
+ if !unmapped_letters.is_empty() {
+ let list: Vec = unmapped_letters.iter().take(10).cloned().collect();
+ warnings.push(format!(
+ "form {}: {} marked option(s) are not options on the printed form ({}), which usually \
+ means a rubric column was added by hand in Gradescope",
+ decoder.form,
+ unmapped_letters.len(),
+ list.join(", ")
+ ));
+ }
+ if translated == 0 {
+ warnings.push(format!(
+ "form {}: no response rows carried this form id, so nothing was translated",
+ decoder.form
+ ));
+ }
+
+ warnings
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::responses::{Response, administration_id};
+
+ fn map(position: u32, number: u32, pairs: &[(&str, &str)], key: &str) -> QuestionMap {
+ let mut to_canonical = BTreeMap::new();
+ let mut to_printed = BTreeMap::new();
+ for (printed, canonical) in pairs {
+ to_canonical.insert(printed.to_string(), canonical.to_string());
+ to_printed.insert(canonical.to_string(), printed.to_string());
+ }
+ let printed_key = vec![to_printed.get(key).cloned().unwrap_or_default()];
+ QuestionMap {
+ position,
+ number,
+ item: format!("b::q-{number}"),
+ printed_key,
+ canonical_key: vec![key.to_string()],
+ to_canonical,
+ to_printed,
+ }
+ }
+
+ fn decoder() -> FormDecoder {
+ FormDecoder::assemble(
+ "B".to_string(),
+ Provenance::Seal,
+ vec![
+ // Printed A..D show bank C, A, D, B. The key is bank A, printed B.
+ map(1, 1, &[("A", "C"), ("B", "A"), ("C", "D"), ("D", "B")], "A"),
+ // A bonus item recorded as 9 but printed last, at position 2.
+ map(2, 9, &[("A", "B"), ("B", "A")], "B"),
+ ],
+ )
+ }
+
+ fn row(number: u32, selected: &str) -> Response {
+ Response {
+ administration_id: administration_id("C", "2026f", "e1"),
+ course: "C".into(),
+ term: "2026f".into(),
+ assessment_id: "e1".into(),
+ date: None,
+ form: Some("B".into()),
+ student_key: "s1".into(),
+ sid: None,
+ name: None,
+ email: None,
+ section: None,
+ item_number: number,
+ form_position: None,
+ item_ref: None,
+ item_version: None,
+ selected: vec![selected.into()],
+ eliminated: Vec::new(),
+ selected_source: Vec::new(),
+ eliminated_source: Vec::new(),
+ correct: Some(false),
+ credit: 0.0,
+ points_possible: 1.0,
+ score: 0.0,
+ response_time_seconds: None,
+ level: None,
+ learning_objectives: Vec::new(),
+ topics: Vec::new(),
+ bonus: false,
+ dropped: false,
+ }
+ }
+
+ #[test]
+ fn printed_letters_become_bank_letters() {
+ let decoder = decoder();
+ let q = decoder.at_position(1).unwrap();
+ assert_eq!(q.canonical("A"), Some("C"));
+ assert_eq!(q.canonical("D"), Some("B"));
+ assert_eq!(q.canonical("E"), None);
+ assert_eq!(q.printed_key, vec!["B".to_string()]);
+ }
+
+ #[test]
+ fn positions_become_recorded_numbers() {
+ let mut set = ResponseSet::new();
+ set.rows.push(row(2, "A"));
+ let warnings = apply(&mut set, &decoder(), Numbering::Printed);
+ assert!(warnings.is_empty(), "{warnings:?}");
+ assert_eq!(set.rows[0].item_number, 9, "position 2 is recorded as 9");
+ assert_eq!(set.rows[0].form_position, Some(2));
+ assert_eq!(set.rows[0].selected, vec!["A".to_string()], "printed kept");
+ assert_eq!(set.rows[0].selected_source, vec!["B".to_string()]);
+ }
+
+ #[test]
+ fn rows_from_another_form_are_left_alone() {
+ let mut set = ResponseSet::new();
+ let mut other = row(1, "A");
+ other.form = Some("A".into());
+ set.rows.push(other);
+ apply(&mut set, &decoder(), Numbering::Printed);
+ assert!(set.rows[0].selected_source.is_empty());
+ assert_eq!(set.rows[0].item_number, 1);
+ }
+
+ #[test]
+ fn an_unknown_position_is_reported_not_guessed() {
+ let mut set = ResponseSet::new();
+ set.rows.push(row(7, "A"));
+ let warnings = apply(&mut set, &decoder(), Numbering::Printed);
+ assert!(
+ warnings.iter().any(|w| w.contains("not on this form")),
+ "{warnings:?}"
+ );
+ assert_eq!(set.rows[0].item_number, 7, "left as found");
+ }
+
+ #[test]
+ fn a_perfect_fit_says_nothing() {
+ let fits = vec![FormFit {
+ form: "A".into(),
+ matched: 30,
+ compared: 30,
+ mismatches: Vec::new(),
+ }];
+ assert!(form_warning("A", &fits).is_none());
+ }
+
+ #[test]
+ fn a_swapped_directory_names_the_form_it_really_is() {
+ let fits = vec![
+ FormFit {
+ form: "B".into(),
+ matched: 30,
+ compared: 30,
+ mismatches: Vec::new(),
+ },
+ FormFit {
+ form: "A".into(),
+ matched: 8,
+ compared: 30,
+ mismatches: (1..=22).collect(),
+ },
+ ];
+ let warning = form_warning("A", &fits).expect("a mismatch this large must warn");
+ assert!(warning.contains("almost certainly form B"), "{warning}");
+ }
+
+ #[test]
+ fn a_single_regraded_key_warns_without_accusing_the_wrong_form() {
+ let fits = vec![FormFit {
+ form: "A".into(),
+ matched: 29,
+ compared: 30,
+ mismatches: vec![14],
+ }];
+ let warning = form_warning("A", &fits).unwrap();
+ assert!(warning.contains("q14"), "{warning}");
+ assert!(warning.contains("regraded"), "{warning}");
+ }
+}
diff --git a/src/data/gradescope.rs b/src/data/gradescope.rs
index 050739c..6f18c7a 100644
--- a/src/data/gradescope.rs
+++ b/src/data/gradescope.rs
@@ -633,6 +633,10 @@ pub fn to_responses(questions: &[Question], ctx: &Context) -> Import {
assessment_id: ctx.assessment_id.clone(),
date: ctx.date,
form: ctx.form.clone(),
+ // The printed position and the bank's lettering are written
+ // later, by `decode::apply`, which is the only place that knows
+ // which form this directory holds.
+ form_position: None,
student_key,
sid: row.sid.clone(),
name: row.name.clone(),
@@ -642,7 +646,9 @@ pub fn to_responses(questions: &[Question], ctx: &Context) -> Import {
item_ref: None,
item_version: None,
selected,
+ selected_source: Vec::new(),
eliminated,
+ eliminated_source: Vec::new(),
correct,
credit,
points_possible: points,
diff --git a/src/data/intake.rs b/src/data/intake.rs
new file mode 100644
index 0000000..385b93b
--- /dev/null
+++ b/src/data/intake.rs
@@ -0,0 +1,565 @@
+// SPDX-License-Identifier: Prosperity-3.0.0
+// Copyright Scientific Computing Studio
+// Source: https://git.scient.ing/education/coursebank
+
+//! Reading several forms of one exam back in at once.
+//!
+//! A two-form exam is two Gradescope assignments, each exporting its own
+//! directory of `1.csv` through `N.csv`, each numbered against its own paper. They
+//! are one administration: one set of students, one item pool, one set of
+//! statistics. Ingesting them one command at a time does not work, because the
+//! store keys on the administration and the second write replaces the first.
+//!
+//! So this module takes the whole set:
+//!
+//! ```text
+//! coursebank ingest gradescope A=exports/e1-a B=exports/e1-b --assessment e1
+//! ```
+//!
+//! and does four things the single-directory path cannot:
+//!
+//! *Checks each directory is the form it claims to be.* Every export carries the
+//! graded key in its point-value row; every form knows what its printed key should
+//! be. Comparing them catches a swapped pair of directories immediately rather
+//! than three weeks later, when the distractor table looks strange. See
+//! [`crate::decode::identify_form`].
+//!
+//! *Translates each form into the bank's vocabulary before merging.* Otherwise
+//! form A's option C and form B's option C land in the same column of the same
+//! table while meaning different things.
+//!
+//! *Merges into one response set*, written once, so `analyze` and `report` see the
+//! whole class.
+//!
+//! *Reports what the merge revealed*: a student who appears on two forms, a form
+//! that is missing a question the other has, a form that ran materially harder
+//! than the other.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::path::{Path, PathBuf};
+
+use crate::assessment::{AssessmentFile, Form};
+use crate::catalog::Catalog;
+use crate::date::Date;
+use crate::decode::{self, FormDecoder, FormFit, Numbering};
+use crate::error::{Error, Result};
+use crate::gradescope::{self, Context, Question};
+use crate::responses::ResponseSet;
+use crate::seal::SealFile;
+
+/// One directory of graded questions, and the form it holds.
+#[derive(Debug, Clone)]
+pub struct Source {
+ /// The form id.
+ pub form: String,
+ /// The directory of per-question CSV exports.
+ pub dir: PathBuf,
+}
+
+impl Source {
+ /// Parses a `FORM=DIR` argument.
+ ///
+ /// A bare path is accepted and takes the fallback form, so the single-form
+ /// case stays as short as it was.
+ ///
+ /// # Arguments
+ ///
+ /// * `text` - the argument, e.g. `A=exports/e1-a` or `exports/e1`.
+ /// * `fallback` - the form to use when the argument names none.
+ ///
+ /// # Returns
+ ///
+ /// The source.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Usage`] when the argument names no form and no fallback
+ /// was given, or when the form label is empty.
+ pub fn parse(text: &str, fallback: Option<&str>) -> Result {
+ // Split on the first `=` only: a directory name may contain one, a form
+ // label may not.
+ if let Some((form, dir)) = text.split_once('=') {
+ let form = form.trim();
+ if form.is_empty() {
+ return Err(Error::usage(format!(
+ "`{text}` has an empty form label; write it as FORM=DIR, e.g. A=exports/e1-a"
+ )));
+ }
+ if !dir.trim().is_empty() {
+ return Ok(Source {
+ form: form.to_string(),
+ dir: PathBuf::from(dir.trim()),
+ });
+ }
+ }
+ match fallback {
+ Some(form) => Ok(Source {
+ form: form.to_string(),
+ dir: PathBuf::from(text.trim()),
+ }),
+ None => Err(Error::usage(format!(
+ "`{text}` does not say which form it holds; write it as FORM=DIR (e.g. \
+ A=exports/e1-a) or pass --form"
+ ))),
+ }
+ }
+}
+
+/// How to run an intake.
+#[derive(Debug, Clone)]
+pub struct Options {
+ /// The administration date, overriding the record's.
+ pub date: Option,
+ /// How the exports number their questions.
+ pub numbering: Numbering,
+ /// Whether a form that fails its key check stops the ingest.
+ ///
+ /// On by default. A directory that does not match the form it was named as is
+ /// the one ingest error that produces confident, wrong analysis rather than an
+ /// obvious failure, so the default is to refuse and say so.
+ pub strict: bool,
+}
+
+impl Default for Options {
+ fn default() -> Options {
+ Options {
+ date: None,
+ numbering: Numbering::Printed,
+ strict: true,
+ }
+ }
+}
+
+/// What one form's directory contributed.
+#[derive(Debug, Clone)]
+pub struct FormIntake {
+ /// The form id.
+ pub form: String,
+ /// The directory it came from.
+ pub dir: PathBuf,
+ /// Where its mapping came from.
+ pub provenance: decode::Provenance,
+ /// How many students it held.
+ pub students: usize,
+ /// How many questions it held.
+ pub questions: usize,
+ /// How the graded keys compared to every declared form, best first.
+ pub fits: Vec,
+ /// The parsed question files, kept so grading-time decisions stay available.
+ pub parsed: Vec,
+}
+
+impl FormIntake {
+ /// This form's own fit.
+ pub fn own_fit(&self) -> Option<&FormFit> {
+ self.fits
+ .iter()
+ .find(|f| f.form.eq_ignore_ascii_case(&self.form))
+ }
+}
+
+/// The result of reading every form of one administration.
+#[derive(Debug, Clone)]
+pub struct Intake {
+ /// The merged, translated responses.
+ pub responses: ResponseSet,
+ /// Per-form detail.
+ pub forms: Vec,
+ /// Problems that did not stop the ingest.
+ pub warnings: Vec,
+}
+
+impl Intake {
+ /// Every parsed question file, across forms.
+ ///
+ /// # Returns
+ ///
+ /// Pairs of form id and question.
+ pub fn questions(&self) -> Vec<(&str, &Question)> {
+ self.forms
+ .iter()
+ .flat_map(|f| f.parsed.iter().map(move |q| (f.form.as_str(), q)))
+ .collect()
+ }
+}
+
+/// Reads every source into one response set.
+///
+/// # Arguments
+///
+/// * `catalog` - the loaded course.
+/// * `record` - the assessment record.
+/// * `seal` - the seal, when one was written. Strongly preferred: it describes the
+/// paper that was printed rather than the paper the bank would print today.
+/// * `sources` - the directories and the forms they hold.
+/// * `opts` - how to run.
+///
+/// # Returns
+///
+/// The merged intake.
+///
+/// # Errors
+///
+/// Returns [`Error::Usage`] when a source names a form the record does not
+/// declare, when two sources name the same form, or when a key check fails under
+/// `strict`. Propagates parse errors from the exports themselves.
+pub fn run(
+ catalog: &Catalog,
+ record: &AssessmentFile,
+ seal: Option<&SealFile>,
+ sources: &[Source],
+ opts: &Options,
+) -> Result {
+ if sources.is_empty() {
+ return Err(Error::usage(
+ "no directories to ingest; pass one per form, e.g. A=exports/e1-a B=exports/e1-b"
+ .to_string(),
+ ));
+ }
+
+ let declared = declared_forms(record);
+ let mut seen: BTreeSet = BTreeSet::new();
+ for source in sources {
+ if !seen.insert(source.form.to_ascii_uppercase()) {
+ return Err(Error::usage(format!(
+ "form {} was given twice; each form is one directory",
+ source.form
+ )));
+ }
+ if !declared
+ .iter()
+ .any(|f| f.id.eq_ignore_ascii_case(&source.form))
+ {
+ return Err(Error::usage(format!(
+ "the record for `{}` declares no form `{}`; it declares {}",
+ record.assessment.id,
+ source.form,
+ declared
+ .iter()
+ .map(|f| f.id.as_str())
+ .collect::>()
+ .join(", ")
+ )));
+ }
+ }
+
+ // One decoder per declared form, not just per ingested form: identifying a
+ // swapped directory means testing it against the forms it might be.
+ let mut decoders: Vec = Vec::new();
+ for form in &declared {
+ decoders.push(FormDecoder::resolve(seal, catalog, record, form)?);
+ }
+
+ let mut merged = ResponseSet::new();
+ let mut forms = Vec::new();
+ let mut warnings = Vec::new();
+ let mut blocking = Vec::new();
+
+ for source in sources {
+ let decoder = decoders
+ .iter()
+ .find(|d| d.form.eq_ignore_ascii_case(&source.form))
+ .expect("every source's form was checked against the declared list");
+
+ let ctx = Context {
+ course: catalog.course.course.code.clone(),
+ term: record
+ .assessment
+ .term
+ .clone()
+ .unwrap_or_else(|| catalog.course.course.term.clone()),
+ assessment_id: record.assessment.id.clone(),
+ date: opts.date.or(record.assessment.date),
+ form: Some(decoder.form.clone()),
+ };
+
+ let import = gradescope::ingest_dir(&source.dir, &ctx)?;
+ let mut set = import.responses;
+
+ let fits = decode::identify_form(&import.questions, &decoders, opts.numbering);
+ if let Some(problem) = decode::form_warning(&decoder.form, &fits) {
+ let message = format!("{} [{}]", problem, source.dir.display());
+ let convincing_alternative = fits
+ .iter()
+ .any(|f| !f.form.eq_ignore_ascii_case(&decoder.form) && f.is_convincing());
+ if opts.strict && convincing_alternative {
+ blocking.push(message);
+ } else {
+ warnings.push(message);
+ }
+ }
+
+ warnings.extend(decode::apply(&mut set, decoder, opts.numbering));
+
+ forms.push(FormIntake {
+ form: decoder.form.clone(),
+ dir: source.dir.clone(),
+ provenance: decoder.provenance,
+ students: set.students().len(),
+ questions: set.all_items().len(),
+ fits,
+ parsed: import.questions,
+ });
+
+ merged.absorb(set);
+ }
+
+ if !blocking.is_empty() {
+ blocking.push(
+ "Nothing was written. Fix the form labels, or pass --allow-mismatch if the keys really \
+ did change after printing."
+ .to_string(),
+ );
+ return Err(Error::Invalid(blocking));
+ }
+
+ warnings.extend(cross_form_checks(&merged, &forms, record));
+ merged.warnings.extend(warnings.clone());
+
+ Ok(Intake {
+ responses: merged,
+ forms,
+ warnings,
+ })
+}
+
+/// The forms a record declares, with the implicit single form for a record that
+/// declares none.
+fn declared_forms(record: &AssessmentFile) -> Vec