23 Commits
Author SHA1 Message Date
alexm 220363d4d3 feat: more comprehensive cohort report
Pipeline / check (pull_request) Failing after 35s
Pipeline / docs (pull_request) Skipped
Pipeline / nightly (pull_request) Skipped
Pipeline / release (pull_request) Skipped
2026-09-22 01:19:35 -04:00
alexm cea9769022 fix: seals after changes 2026-09-22 00:58:32 -04:00
alexm d3f21e913b fix: exam report targets and objectives
Pipeline / check (pull_request) Successful in 2m43s
Pipeline / docs (pull_request) Skipped
Pipeline / nightly (pull_request) Skipped
Pipeline / release (pull_request) Skipped
2026-09-22 00:39:22 -04:00
alexm cfe8a3216c fix: rendering in typst
Pipeline / check (pull_request) Successful in 3m8s
Pipeline / docs (pull_request) Skipped
Pipeline / nightly (pull_request) Skipped
Pipeline / release (pull_request) Skipped
2026-09-22 00:19:40 -04:00
alexm 003340dc6f fix: handle targets
Pipeline / check (pull_request) Failing after 2m38s
Pipeline / docs (pull_request) Skipped
Pipeline / nightly (pull_request) Skipped
Pipeline / release (pull_request) Skipped
2026-09-21 15:24:16 -04:00
alexm 8ec48fb185 feat: add learning objective targets
Pipeline / check (pull_request) Failing after 3m25s
Pipeline / docs (pull_request) Skipped
Pipeline / nightly (pull_request) Skipped
Pipeline / release (pull_request) Skipped
2026-09-21 14:02:34 -04:00
alexm 9755417899 feat: add more report cli options
Pipeline / check (pull_request) Failing after 21s
Pipeline / docs (pull_request) Skipped
Pipeline / nightly (pull_request) Skipped
Pipeline / release (pull_request) Skipped
2026-09-20 10:54:39 -04:00
alexm beec860a2c refactor: better what to read 2026-09-20 09:40:00 -04:00
alexm cea03048b8 feat: improve dropped question support 2026-09-20 00:41:26 -04:00
alexm ba84c4d82a chore: add dropped question to reports 2026-09-19 23:15:31 -04:00
alexm 92c8a8fc75 refactor: improve cohort report 2026-09-19 22:19:25 -04:00
alexm c06d5caa8c refactor: improve student report 2026-09-19 21:30:04 -04:00
alexm 994e9065e8 feat: implement reports 2026-09-19 20:06:41 -04:00
alexm 6ae993d393 fix: improve key 2026-09-14 13:01:09 -04:00
alexm 1b6b1f99fd feat: cleanup exam template 2026-09-14 05:22:12 -04:00
alexm 51f095d677 fix: handle latex equations when rendering typst 2026-09-14 04:20:15 -04:00
alexm b375e6c94f feat: fix exporting to pdf 2026-08-30 21:20:03 -04:00
alexm d38d89549c fix: improve canvas export 2026-08-26 21:24:22 -04:00
alexm 4cd33f1768 fix: handling of equations in QTI 2026-08-17 01:37:01 -04:00
alexm 1cc8137be9 feat: write quarto assignment with encryption 2026-08-10 03:25:15 -04:00
alexm 327ac371e4 feat: improve worksheet 2026-08-10 01:52:15 -04:00
alexm f475c630e0 feat: support readings and lecture rendering 2026-08-08 16:24:50 -04:00
alexm 468af3a815 fix: merge .gitignore instead of overwrite 2026-08-08 13:06:48 -04:00
58 changed files with 19364 additions and 535 deletions
+1
View File
@@ -1,4 +1,5 @@
preview
scratch
/dist/
/THIRD-PARTY-LICENSES.txt
+8 -7
View File
@@ -17,10 +17,6 @@ path = "src/main.rs"
name = "coursebank"
path = "src/lib.rs"
[features]
default = ["parquet"]
parquet = ["dep:parquet", "dep:arrow-array", "dep:arrow-schema"]
[dependencies]
clap = { version = "4", features = ["derive"] }
csv = "1"
@@ -29,9 +25,14 @@ serde_json = "1"
serde_yaml_ng = "0.10"
thiserror = "2"
arrow-array = { version = "55", optional = true }
arrow-schema = { version = "55", optional = true }
parquet = { version = "55", optional = true }
arrow-array = { version = "55"}
arrow-schema = { version = "55"}
parquet = { version = "55"}
aes-gcm = { version = "0.10"}
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"]}
sha2 = { version = "0.10"}
base64 = { version = "0.22"}
getrandom = { version = "0.2"}
[profile.release]
opt-level = 3
+192
View File
@@ -0,0 +1,192 @@
# An assignment on the web
This follows a single homework from an assembled record to a published Quarto page whose solutions stay locked until a student enters a password.
It assumes a course directory with approved items and an assembled assessment; if you do not have one, [`setup`](crate::guide::setup) and [`first_exam`](crate::guide::first_exam) build both.
The site export is the third off-Canvas path, alongside the printed exam in [`typst_export`](crate::guide::typst_export) and the plain-text worksheet from `export practice`.
The difference is where the solutions go: printed on a key you keep, or encrypted into a bundle that ships with the page and unlocks in the browser.
## Assemble the homework
Assemble it the same way you assemble an exam, with the platform set to the website rather than paper or Canvas:
```console
$ coursebank assemble a1.1 \
--title "Homework 1" \
--kind homework --platform other \
--levels 2=1,3=1 --lectures L1.1 --seed 20260210
```
What lands in `assessments/a1.1.yaml` is a record of the draw.
The id `a1.1` is the one thing to choose deliberately here: it becomes the bundle's file name and the name the page's gate points at, so keep it URL-safe and stable.
A single unshuffled form is fine for homework.
To hand different students different option orders, add `--forms 2` and export each form separately; the printed choices and the letters the solutions refer to move together, because both come from the form's recorded seed.
## Export for the web
```console
$ coursebank export site a1.1 --out build/a1.1 --assets build/static
password for a1.1: k7m4-9p2q-r8tx-3wn6
wrote build/a1.1/_questions.qmd
wrote build/a1.1/a1.1-solutions.json
wrote build/static/questions.css
wrote build/static/solutions.js
Include in the page with: {{< include _questions.qmd >}}
```
That is four files and a password.
`_questions.qmd` is the partial you include from the page.
`a1.1-solutions.json` is the encrypted bundle, named for the assessment so several assignments can share one site.
The two files under `build/static` style the questions and perform the unlock; they install once for the whole site, not once per page, so `--assets` is something you run the first time and drop afterward.
The password is printed once and written nowhere.
Record it now.
It cannot be recovered from the files, which is the point: the bundle is useless without it, so losing it means re-exporting rather than reading it back.
## What the partial holds
The questions are Quarto fenced divs, with an empty, hidden slot where each solution will land:
```text
::: {.solutions-gate data-bundle="a1.1-solutions.json"}
:::
::: {.q #q-enthalpy-qp-001}
:::: {.q-head}
[Question 1]{.q-num} [Single best answer]{.q-kind} [1 point]{.q-points}
::::
:::: {.q-stem}
A reaction is run in an open flask, so the system stays at the constant
pressure of the room. The heat the reaction exchanges with its surroundings
equals the change in which quantity?
::::
:::: {.q-choices}
1. Enthalpy, $\Delta H$
2. Internal energy, $\Delta U$
3. Gibbs free energy, $\Delta G$
4. Entropy, $\Delta S$
::::
:::: {.qsol data-solution-for="q-enthalpy-qp-001" hidden="true"}
::::
:::
```
The choices are printed without letters, and the stylesheet draws the A, B, C, D from their position.
There is nothing in this file to leak: no `correct` flag, no solution text, no rationale.
The `.qsol` slot is empty until the browser fills it, and the id in `data-solution-for` is the key the bundle looks up.
Math is written in `$ … $` and passed through untouched, so MathJax typesets it in the page.
## What the bundle holds
Every solution is rendered to HTML, then encrypted:
```json
{
"v": 1,
"page": "a1.1",
"kdf": { "name": "PBKDF2", "hash": "SHA-256", "iterations": 250000, "salt": "jqbE6xIB..." },
"cipher": "AES-GCM",
"items": {
"q-enthalpy-qp-001": { "iv": "8jzuxY...", "ct": "gmzNSlhioU...(+tag)" },
"q-enthalpy-derive-001": { "iv": "1BddCd...", "ct": "sC8czL547/...(+tag)" }
}
}
```
The password derives an AES-256 key through PBKDF2-HMAC-SHA256 at 250,000 iterations over a random salt.
Each solution is encrypted under its own random IV, with the authentication tag appended to the ciphertext.
The plaintext HTML never leaves your machine.
Items are listed in the order the questions appear, not sorted, so the reader's browser can decrypt the first one to check the password before touching the rest.
## Wire it into the site
Install the two assets once in `_quarto.yml`:
```yaml
format:
html:
css:
- static/questions.css
include-after-body:
- static/solutions.js
```
Put `_questions.qmd` and `a1.1-solutions.json` in the page's own directory, and include the partial from the page:
```markdown
---
title: "Homework 1"
---
{{< include _questions.qmd >}}
```
The gate resolves `data-bundle` relative to the page URL, so keeping the bundle beside the page is enough.
If your build prunes files it does not see linked, add `resources: ["*-solutions.json"]` to the page front matter so the bundle ships with the render.
Render with `quarto render`.
A reader who opens the page sees the questions and a locked panel; typing the password decrypts the solutions in place, with the math typeset.
## Hand out the password, and rotate it
Give the password through a channel students already have, such as the course LMS, rather than the site itself.
Be clear-eyed about what the lock does.
It keeps solutions off a public page until someone has the password.
It does not make them secret in a strong sense: the whole bundle is downloaded, so anyone with the password, or anyone they share it with, can decrypt every item, and the ciphertext is open to an offline guessing attack.
Eighty bits of password entropy and a quarter-million PBKDF2 iterations make guessing slow, but the right mental model is a lock on a take-home worksheet, not a grading system of record.
Rotate the password after the due date by re-running the export, which mints a fresh one and a freshly encrypted bundle:
```console
$ coursebank export site a1.1 --out build/a1.1
password for a1.1: 2h9k-w4rq-8mnp-x6tv
...
```
To set a password yourself instead of generating one, pass `--password`.
Use that only when you have a reason to, such as re-encrypting an unchanged page with a password you already circulated.
## Doing this from Rust
The CLI is a thin wrapper over `coursebank::site`, which is compiled only with the `site` feature.
The example below needs `--features site` to build, so it is not run as a doctest:
```rust,ignore
use std::path::Path;
use coursebank::assessment::{AssessmentFile, Form};
use coursebank::site::{self, Options};
use coursebank::Catalog;
fn main() -> coursebank::Result<()> {
let catalog = Catalog::load(Path::new("."))?;
let path = catalog.layout.assessments().join("a1.1.yaml");
let record = AssessmentFile::load(&path)?;
// An unshuffled form A; declare a form with a seed to shuffle.
let form = Form { id: "A".into(), seed: 0, shuffle_items: false, shuffle_options: false };
// `password: None` generates a fresh one; pass `Some(_)` to set your own.
let rendered = site::render(&catalog, &record, Options { form, password: None })?;
std::fs::write("build/a1.1/_questions.qmd", &rendered.questions_qmd)?;
std::fs::write(
format!("build/a1.1/{}-solutions.json", rendered.page),
&rendered.solutions_json,
)?;
// The password is the one thing not on disk; print it now.
println!("password for {}: {}", rendered.page, rendered.password);
Ok(())
}
```
`site::assets()` returns the two browser files as name and contents, if you would rather write them from your own code than pass `--assets`.
+67
View File
@@ -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.
+1
View File
@@ -35,5 +35,6 @@
pub mod calibrate;
pub mod classical;
pub mod diagnostic;
pub mod irt;
pub mod students;
+160 -21
View File
@@ -61,6 +61,13 @@ pub struct Thresholds {
pub nonfunctioning: f64,
/// How far observed difficulty may drift from the authored expectation.
pub design_tolerance: f64,
/// How many examinees an item's calibration needs before its recorded
/// expectations are treated as evidence rather than as the author's guess.
///
/// Fifty is the point at which the standard error of a proportion near 0.5
/// drops to about 0.07, which is small enough that a quarter-point miss is
/// about the item rather than about the sample.
pub calibrated_n: usize,
/// Fraction of the class in the upper and lower comparison groups. Kelley's
/// 0.27 maximizes the difference between the groups for a normal
/// distribution, and it remains the convention.
@@ -78,6 +85,7 @@ impl Default for Thresholds {
negative_discrimination: -0.05,
nonfunctioning: 0.05,
design_tolerance: 0.25,
calibrated_n: 50,
group_fraction: 0.27,
small_sample: 100,
}
@@ -154,6 +162,38 @@ pub struct ItemAnalysis {
pub flags: Vec<Flag>,
/// Human-readable explanations tied to the flags.
pub notes: Vec<String>,
/// How the item behaved against what its author predicted, when the item
/// records a prediction.
pub prediction: Option<Prediction>,
}
/// An authored expectation, checked against what happened.
///
/// Kept apart from [`ItemAnalysis::flags`] on purpose. Before an item has been
/// administered, `design.expected_difficulty` is the author's guess, and a guess
/// that turns out wrong says something about the guess rather than about the
/// item. Flagging it anyway is how a report ends up with thirty
/// `design_mismatch` findings and no way to see the four that matter. So the
/// discrepancy is always recorded here, and it only becomes a
/// [`Flag::DesignMismatch`] once the expectation has data behind it.
#[derive(Debug, Clone)]
pub struct Prediction {
/// The difficulty the author expected.
pub expected_p: Option<f64>,
/// The discrimination band the author expected, as `(low, high)`.
pub expected_band: Option<(f64, f64)>,
/// Whether the expectation rests on a calibration with enough examinees
/// behind it, rather than on the author's judgement alone.
pub calibrated: bool,
/// Signed difficulty error, observed minus expected. Positive means the item
/// was easier than predicted.
pub p_error: Option<f64>,
/// Whether observed difficulty landed inside the tolerance.
pub p_within: Option<bool>,
/// Whether observed discrimination landed inside the expected band.
pub band_hit: Option<bool>,
/// What to say about it, phrased for whichever case applies.
pub notes: Vec<String>,
}
impl ItemAnalysis {
@@ -356,6 +396,39 @@ pub fn analyze(
));
}
// A drop changes every student's percentage, so the two ways to get it wrong
// are worth saying out loud. Both are silent otherwise: the numbers simply
// come out different from the platform's.
if let Some(record) = record {
for placement in record.items.iter().filter(|p| p.dropped) {
let rows = set.for_item(placement.number);
if rows.is_empty() {
continue;
}
let all_credited = rows.iter().all(|r| r.credit >= 0.999);
if placement.dropped_with_credit() && !all_credited {
let short = rows.iter().filter(|r| r.credit < 0.999).count();
warnings.push(format!(
"question {} is marked `dropped_as: full_credit`, but {short} of {} responses \
carry less than full credit. Either the platform was not regraded or the \
export predates the regrade; until one of those is fixed this report's \
percentages will sit below the grade of record",
placement.number,
rows.len()
));
}
if !placement.dropped_with_credit() && all_credited {
warnings.push(format!(
"question {} is dropped and every response carries full credit, which is what \
crediting every option on the platform looks like. It is being removed from \
the denominator here, so this report will read slightly lower than the \
platform. Set `dropped_as: full_credit` if the platform kept the point",
placement.number
));
}
}
}
let mut items = Vec::new();
let mut p_values = Vec::new();
let mut rpbs = Vec::new();
@@ -406,13 +479,13 @@ pub fn analyze(
let mut credits: BTreeMap<String, Vec<f64>> = 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);
}
@@ -493,9 +566,29 @@ pub fn analyze(
options,
flags: Vec::new(),
notes: Vec::new(),
prediction: None,
};
flag_item(&mut analysis, t, design.as_ref(), &rows);
// Whether the authored expectation is evidence or a guess. An item that
// has never been administered has no calibration block, and one edited
// since its last calibration has a fingerprint that no longer matches.
let calibrated = record
.and_then(|r| r.placement(*number))
.and_then(|p| catalog.and_then(|c| c.get(&p.item)))
.and_then(|entry| {
let cal = entry.item.calibration.as_ref()?;
let enough = cal.n_examinees.unwrap_or(0) >= t.calibrated_n;
let current = match &cal.fingerprint {
Some(recorded) => *recorded == entry.item.fingerprint(),
// An older calibration block with no fingerprint cannot be
// shown stale, so it is taken at its word.
None => true,
};
Some(enough && current)
})
.unwrap_or(false);
flag_item(&mut analysis, t, design.as_ref(), &rows, calibrated);
p_values.push(p_value);
if let Some(r) = rpb {
@@ -521,11 +614,13 @@ pub fn analyze(
/// * `t` - the thresholds.
/// * `design` - the authored expectation, when available.
/// * `rows` - the raw responses, for partial-credit detection.
/// * `calibrated` - whether that expectation rests on prior data.
fn flag_item(
a: &mut ItemAnalysis,
t: &Thresholds,
design: Option<&Design>,
rows: &[&crate::responses::Response],
calibrated: bool,
) {
// Discrimination first: it is the finding that changes what you do.
match a.point_biserial {
@@ -678,31 +773,71 @@ fn flag_item(
}
}
// Did the item behave as authored?
// Did the item behave as authored? This is the one check whose meaning
// depends on where the expectation came from, so it is recorded either way
// and flagged only when the expectation had data behind it.
if let Some(d) = design {
let mut prediction = Prediction {
expected_p: d.expected_difficulty,
expected_band: d.expected_discrimination.map(|b| b.expected_band()),
calibrated,
p_error: None,
p_within: None,
band_hit: None,
notes: Vec::new(),
};
if let Some(expected) = d.expected_difficulty {
if (expected - a.p_value).abs() > t.design_tolerance {
a.flags.push(Flag::DesignMismatch);
a.notes.push(format!(
"you expected about {:.0}% correct and observed {:.0}%. Worth knowing whether \
your model of the students or the item is off.",
expected * 100.0,
a.p_value * 100.0
));
let error = a.p_value - expected;
let within = error.abs() <= t.design_tolerance;
prediction.p_error = Some(error);
prediction.p_within = Some(within);
if !within {
if calibrated {
a.flags.push(Flag::DesignMismatch);
prediction.notes.push(format!(
"this item is calibrated at about {:.0}% correct and came out at {:.0}%. \
Something changed: the cohort, the teaching, or the item.",
expected * 100.0,
a.p_value * 100.0
));
} else {
prediction.notes.push(format!(
"you predicted about {:.0}% correct and observed {:.0}%. This is the \
first data on the item, so it corrects the prediction rather than \
condemning the item.",
expected * 100.0,
a.p_value * 100.0
));
}
}
}
if let (Some(band), Some(r)) = (d.expected_discrimination, a.point_biserial) {
let (low, high) = band.expected_band();
if r < low || r > high {
if !a.flags.contains(&Flag::DesignMismatch) {
a.flags.push(Flag::DesignMismatch);
let hit = r >= low && r <= high;
prediction.band_hit = Some(hit);
if !hit {
if calibrated {
if !a.flags.contains(&Flag::DesignMismatch) {
a.flags.push(Flag::DesignMismatch);
}
prediction.notes.push(format!(
"calibrated for {} discrimination ({low:.2} to {high:.2}), observed \
{r:.2}.",
format!("{band:?}").to_lowercase()
));
} else {
prediction.notes.push(format!(
"you predicted {} discrimination ({low:.2} to {high:.2}) and observed \
{r:.2}.",
format!("{band:?}").to_lowercase()
));
}
a.notes.push(format!(
"you expected {} discrimination ({low:.2} to {high:.2}) and observed {r:.2}.",
format!("{band:?}").to_lowercase()
));
}
}
a.prediction = Some(prediction);
}
a.flags.sort();
@@ -775,7 +910,7 @@ fn infer_key(rows: &[&crate::responses::Response]) -> Vec<String> {
let mut out: BTreeSet<String> = 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 +992,7 @@ mod tests {
assessment_id: "a".into(),
date: None,
form: None,
form_position: None,
student_key: student.into(),
sid: None,
name: None,
@@ -870,17 +1006,20 @@ 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,
score: credit,
response_time_seconds: None,
level: None,
learning_objectives: vec![],
learning_targets: vec![],
topics: vec![],
bonus: false,
dropped: false,
dropped_full_credit: false,
}
}
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -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 {
+331 -62
View File
@@ -24,6 +24,28 @@
//! uses the interval, so it is honest). A student can be "meeting" an objective
//! provisionally, and the report says so.
//!
//! # Which tier gets classified
//!
//! The registry has two tiers, objectives and their targets (see
//! [`crate::course::Objective`]), and they are reported differently because the
//! evidence behind them differs in kind. An **objective** is classified: its
//! denominator is every item tagged to any of its targets, which is how an exam
//! that spends twelve questions across a topic gets to make one statement with a
//! real denominator instead of twelve statements with none.
//!
//! A **target** is not classified. It usually carries one or two items, and
//! `min_items_for_mastery` would mark almost all of them "not enough evidence",
//! which would be true but useless. So target rows report the observed rate as
//! itemized evidence for the objective's classification, and a report should
//! present them that way: not "you have not mastered this" but "here is what you
//! missed inside the objective above".
//!
//! An item tagged with two targets of the same objective counts *once* toward
//! that objective. Double counting is right across unrelated objectives, where
//! the question "how is this student doing on kinetics" should use every item
//! that measured kinetics, but within one denominator it would inflate both the
//! count and the confidence.
//!
//! # Comparison to the cohort
//!
//! Per-level performance is reported against the class rather than in absolute
@@ -33,10 +55,10 @@
use std::collections::{BTreeMap, BTreeSet};
use crate::course::{CourseFile, Policy};
use crate::course::CourseFile;
use crate::responses::{Response, ResponseSet};
use crate::rng::Rng;
use crate::taxonomy::Level;
use crate::taxonomy::{Level, Tier};
/// How well a student has met one objective.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -74,14 +96,27 @@ impl Mastery {
}
}
/// One student's standing on one objective.
/// One student's standing on one registry entry, at either tier.
#[derive(Debug, Clone)]
pub struct ObjectiveMastery {
/// The objective id.
pub objective: String,
/// The registry id this row reports on, at either tier.
pub id: String,
/// The objective text, for reports.
pub text: String,
/// How many items on this objective the student saw.
/// Which tier this row is, since only one of them is a classification.
pub tier: Tier,
/// The objective this row sits under, for a target row.
pub objective: Option<String>,
/// For an objective row, how many of its targets the exam reached.
///
/// A student report can say "four of the nine things under this objective
/// were tested", which is the honest scope of the claim. Zero for a target
/// row and for an objective with no targets.
pub targets_seen: usize,
/// For an objective row, how many targets it has in the registry.
pub targets_total: usize,
/// How many items the student saw. For an objective row, items tagged to any
/// of its targets, counted once each.
pub n_items: usize,
/// How many they got right, counting partial credit.
pub credit: f64,
@@ -145,8 +180,8 @@ pub struct MissedItem {
pub credit: f64,
/// The level.
pub level: Option<Level>,
/// The objectives involved.
pub learning_objectives: Vec<String>,
/// The learning targets the question measured.
pub learning_targets: Vec<String>,
/// The misconception the chosen distractor was written to detect.
pub misconception: Option<String>,
/// Feedback written for a student who chose that option.
@@ -208,7 +243,13 @@ impl StudentSummary {
pub struct Cohort {
/// Per-student summaries, sorted by key.
pub students: Vec<StudentSummary>,
/// Class rate per objective.
/// Class rate per target, which is the tier items are tagged at. Use it to
/// drill into an objective the class missed.
pub target_rates: BTreeMap<String, f64>,
/// Class rate per objective, with each item counted once.
///
/// This is the class-level table worth acting on, and the one
/// [`Cohort::class_gaps`] is drawn from.
pub objective_rates: BTreeMap<String, f64>,
/// Class rate per level.
pub level_rates: BTreeMap<Level, f64>,
@@ -218,6 +259,11 @@ pub struct Cohort {
pub sd_percent: f64,
/// Objectives the class as a whole did not meet, worst first. This is the
/// list that should change what you reteach.
///
/// Objectives rather than targets, because a list of forty targets below
/// threshold is a list nobody reteaches from, and because a target that
/// carried one item on this exam does not support the claim that the class
/// missed it.
pub class_gaps: Vec<(String, f64)>,
/// Optional grouping of students by response profile.
pub archetypes: Vec<Archetype>,
@@ -289,7 +335,8 @@ pub fn summarize(
let students = set.students();
// Class rates first: every student's report is relative to these.
let objective_rates = rates_by_objective(&set.rows.iter().collect::<Vec<_>>());
let target_rates = rates_by_target(&set.rows.iter().collect::<Vec<_>>());
let objective_rates = rates_by_objective(&set.rows.iter().collect::<Vec<_>>(), course);
let level_rates = rates_by_level(&set.rows.iter().collect::<Vec<_>>());
// Per-level spread across students, for the z comparisons.
@@ -345,28 +392,62 @@ pub fn summarize(
.count();
let n_items = rows.iter().filter(|r| r.counts()).count();
// Objectives, in the course's declared order so reports read the way the
// course is taught rather than alphabetically.
let per_objective = rates_by_objective(&rows);
let counts = counts_by_objective(&rows);
// The registry in the course's declared order, so a report reads the way
// the course is taught rather than alphabetically. Each objective the
// exam reached is followed by the targets it reached, which is the order
// a report wants them in: the claim, then its evidence.
let target_counts = counts_by_target(&rows);
let objective_counts = counts_by_objective(&rows, course);
let mut objectives = Vec::new();
let mut seen: BTreeSet<&String> = BTreeSet::new();
for id in order.iter().chain(per_objective.keys()) {
if !seen.insert(id) {
let mut seen: BTreeSet<String> = BTreeSet::new();
// `order` puts each objective ahead of its own targets, so walking it
// produces the tiering. Anything the exam measured that the registry
// does not know about is appended afterwards rather than dropped.
let measured: Vec<String> = target_counts.keys().cloned().collect();
for id in order.iter().cloned().chain(measured) {
if !seen.insert(id.clone()) {
continue;
}
let Some((n, credit)) = counts.get(id).copied() else {
continue;
};
objectives.push(objective_mastery(
id,
course,
n,
credit,
objective_rates.get(id).copied().unwrap_or(0.0),
&rows,
policy,
));
if course.is_objective(&id) {
let Some((n, credit)) = objective_counts.get(&id).copied() else {
continue;
};
let targets = course.targets(&id);
let reached = targets
.iter()
.filter(|target| target_counts.contains_key(**target))
.count();
objectives.push(objective_mastery(
&id,
course,
n,
credit,
objective_rates.get(&id).copied().unwrap_or(0.0),
&rows,
policy.min_items_for_mastery.max(1),
reached,
targets.len(),
));
} else {
let Some((n, credit)) = target_counts.get(&id).copied() else {
continue;
};
// One item is the normal case for a target, so it is reported
// rather than withheld. The objective row above it carries the
// classification.
objectives.push(objective_mastery(
&id,
course,
n,
credit,
target_rates.get(&id).copied().unwrap_or(0.0),
&rows,
1,
0,
0,
));
}
}
// Levels.
@@ -400,25 +481,29 @@ pub fn summarize(
// not: telling a student to review something they may already know costs
// them an hour, while telling them they have mastered something they have
// not costs them the next exam.
//
// Both lists are drawn from objective rows only. A focus list built from
// targets is as long as the exam and tells a student to review forty
// things, which is the same as telling them nothing; the objective list
// is short enough to act on, and the target rows underneath it say what
// to look at within each one.
let strengths: Vec<String> = objectives
.iter()
.filter(|o| o.status == Mastery::Meeting && o.confident)
.map(|o| o.objective.clone())
.filter(|o| o.tier == Tier::Objective && o.status == Mastery::Meeting && o.confident)
.map(|o| o.id.clone())
.collect();
let mut focus_pairs: Vec<(&ObjectiveMastery, f64)> = objectives
.iter()
.filter(|o| o.tier == Tier::Objective)
.filter(|o| matches!(o.status, Mastery::NotYet | Mastery::Developing))
.map(|o| (o, o.rate))
.collect();
focus_pairs.sort_by(|a, b| {
a.1.partial_cmp(&b.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.objective.cmp(&b.0.objective))
.then_with(|| a.0.id.cmp(&b.0.id))
});
let focus: Vec<String> = focus_pairs
.iter()
.map(|(o, _)| o.objective.clone())
.collect();
let focus: Vec<String> = focus_pairs.iter().map(|(o, _)| o.id.clone()).collect();
let missed = missed_items(&rows, catalog, course);
@@ -460,6 +545,7 @@ pub fn summarize(
Cohort {
students: summaries,
target_rates,
objective_rates,
level_rates,
mean_percent,
@@ -469,21 +555,26 @@ pub fn summarize(
}
}
/// Builds one objective's mastery record.
/// Builds one objective's record, at either tier.
///
/// # Arguments
///
/// * `id` - the objective id.
/// * `course` - the course, for text and policy.
/// * `n` - items on this objective.
/// * `course` - the course, for text, tier, and policy.
/// * `n` - items counting toward this row.
/// * `credit` - total credit earned.
/// * `cohort_rate` - the class rate.
/// * `cohort_rate` - the class rate for the same row.
/// * `rows` - the student's responses, for the level list.
/// * `policy` - the course policy.
/// * `min_items` - items required before the row is classified. The policy's
/// `min_items_for_mastery` for an objective row, and 1 for a target row, which
/// is evidence rather than a classification.
/// * `targets_seen` - targets this exam reached, for an objective row.
/// * `targets_total` - targets in the registry, for an objective row.
///
/// # Returns
///
/// The record.
#[allow(clippy::too_many_arguments)]
fn objective_mastery(
id: &str,
course: &CourseFile,
@@ -491,12 +582,15 @@ fn objective_mastery(
credit: f64,
cohort_rate: f64,
rows: &[&Response],
policy: &Policy,
min_items: usize,
targets_seen: usize,
targets_total: usize,
) -> ObjectiveMastery {
let policy = &course.policy;
let rate = if n > 0 { credit / n as f64 } else { 0.0 };
let (lower, upper) = wilson(credit, n, 1.96);
let status = if n < policy.min_items_for_mastery.max(1) {
let status = if n < min_items.max(1) {
Mastery::NotEnoughEvidence
} else if rate >= policy.mastery_threshold {
Mastery::Meeting
@@ -506,17 +600,35 @@ fn objective_mastery(
Mastery::NotYet
};
// Levels the row was assessed at. For an objective row this is every level
// any of its targets was assessed at, which is what makes "met this
// objective" a checkable claim: meeting it on three Remember items is a
// different statement from meeting it on three Analyze items.
let levels: Vec<Level> = rows
.iter()
.filter(|r| r.learning_objectives.iter().any(|o| o == id))
.filter(|r| {
r.learning_targets
.iter()
.any(|t| t == id || course.objective_for(t) == id)
})
.filter_map(|r| r.level)
.collect::<BTreeSet<Level>>()
.into_iter()
.collect();
ObjectiveMastery {
objective: id.to_string(),
text: course.objective_text(id),
id: id.to_string(),
text: course.text_for(id),
tier: if course.is_objective(id) {
Tier::Objective
} else {
Tier::Target
},
objective: course
.is_target(id)
.then(|| course.objective_for(id).to_string()),
targets_seen,
targets_total,
n_items: n,
credit,
rate,
@@ -563,7 +675,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,10 +704,10 @@ 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(),
learning_targets: r.learning_targets.clone(),
misconception,
feedback,
study,
@@ -612,9 +724,9 @@ fn missed_items(
///
/// # Returns
///
/// The rate for each objective mentioned.
pub fn rates_by_objective(rows: &[&Response]) -> BTreeMap<String, f64> {
counts_by_objective(rows)
/// The rate for each target mentioned.
pub fn rates_by_target(rows: &[&Response]) -> BTreeMap<String, f64> {
counts_by_target(rows)
.into_iter()
.map(|(id, (n, credit))| {
let rate = if n > 0 { credit / n as f64 } else { 0.0 };
@@ -623,11 +735,12 @@ pub fn rates_by_objective(rows: &[&Response]) -> BTreeMap<String, f64> {
.collect()
}
/// Item counts and credit per objective.
/// Item counts and credit per target, as tagged.
///
/// An item tagged with two objectives counts toward both. That double counting is
/// An item tagged with two targets counts toward both. That double counting is
/// intentional: the question "how is this student doing on kinetics" should use
/// every item that measured kinetics.
/// every item that measured kinetics. Roll-up to the objective, where the same
/// item must count once, is [`counts_by_objective`].
///
/// # Arguments
///
@@ -635,14 +748,14 @@ pub fn rates_by_objective(rows: &[&Response]) -> BTreeMap<String, f64> {
///
/// # Returns
///
/// `(item count, total credit)` per objective.
pub fn counts_by_objective(rows: &[&Response]) -> BTreeMap<String, (usize, f64)> {
/// `(item count, total credit)` per target.
pub fn counts_by_target(rows: &[&Response]) -> BTreeMap<String, (usize, f64)> {
let mut out: BTreeMap<String, (usize, f64)> = BTreeMap::new();
for r in rows {
if !r.counts() {
continue;
}
for objective in &r.learning_objectives {
for objective in &r.learning_targets {
let e = out.entry(objective.clone()).or_insert((0, 0.0));
e.0 += 1;
e.1 += r.credit.clamp(0.0, 1.0);
@@ -651,6 +764,66 @@ pub fn counts_by_objective(rows: &[&Response]) -> BTreeMap<String, (usize, f64)>
out
}
/// Item counts and credit per objective.
///
/// Each response contributes at most once to any one objective, even when it is
/// tagged with several of that objective's targets. Within a single denominator,
/// counting an item twice would inflate both the rate's weight and the
/// confidence interval's tightness, and the interval is the part of the report
/// that is supposed to stay honest. Across unrelated objectives an item still
/// counts toward each, as it does in [`counts_by_target`].
///
/// # Arguments
///
/// * `rows` - the responses.
/// * `course` - the course, for the objective each tagged target belongs to.
///
/// # Returns
///
/// `(item count, total credit)` per objective.
pub fn counts_by_objective(
rows: &[&Response],
course: &CourseFile,
) -> BTreeMap<String, (usize, f64)> {
let mut out: BTreeMap<String, (usize, f64)> = BTreeMap::new();
for r in rows {
if !r.counts() {
continue;
}
let objectives: BTreeSet<&str> = r
.learning_targets
.iter()
.map(|t| course.objective_for(t))
.collect();
for id in objectives {
let e = out.entry(id.to_string()).or_insert((0, 0.0));
e.0 += 1;
e.1 += r.credit.clamp(0.0, 1.0);
}
}
out
}
/// Credit rate per objective.
///
/// # Arguments
///
/// * `rows` - the responses.
/// * `course` - the course, for the objective each tagged target belongs to.
///
/// # Returns
///
/// The rate for each objective the responses reached.
pub fn rates_by_objective(rows: &[&Response], course: &CourseFile) -> BTreeMap<String, f64> {
counts_by_objective(rows, course)
.into_iter()
.map(|(id, (n, credit))| {
let rate = if n > 0 { credit / n as f64 } else { 0.0 };
(id, rate)
})
.collect()
}
/// Credit rate per level.
///
/// # Arguments
@@ -1007,21 +1180,113 @@ mod tests {
}
#[test]
fn objective_counts_credit_every_tagged_item() {
fn target_counts_credit_every_tagged_item() {
let rows = [
make("s1", 1, 1.0, &["lo-a", "lo-b"], Some(Level::Remember)),
make("s1", 2, 0.0, &["lo-a"], Some(Level::Apply)),
];
let refs: Vec<&Response> = rows.iter().collect();
let counts = counts_by_objective(&refs);
let counts = counts_by_target(&refs);
// lo-a saw both items; lo-b only the first.
assert_eq!(counts["lo-a"], (2, 1.0));
assert_eq!(counts["lo-b"], (1, 1.0));
let rates = rates_by_objective(&refs);
let rates = rates_by_target(&refs);
assert_eq!(rates["lo-a"], 0.5);
assert_eq!(rates["lo-b"], 1.0);
}
/// A course with one objective over three targets, plus an objective with
/// no targets of its own.
fn tiered_course() -> CourseFile {
serde_yaml_ng::from_str(
r#"
course: { code: X, title: Y, term: Z }
policy: { mastery_threshold: 0.75, min_items_for_mastery: 2 }
learning_objectives:
lo-binding: { text: Quantify binding., order: 1 }
lo-standalone: { text: Untiered objective., order: 2 }
learning_targets:
t-kd: { text: Write the expression., objective: lo-binding, order: 1 }
t-plot: { text: Read a plot., objective: lo-binding, order: 2 }
t-window: { text: State the switching window., objective: lo-binding, order: 3 }
"#,
)
.expect("course parses")
}
#[test]
fn items_on_targets_roll_up_to_their_objective() {
let course = tiered_course();
let rows = [
make("s1", 1, 1.0, &["t-kd"], Some(Level::Remember)),
make("s1", 2, 0.0, &["t-plot"], Some(Level::Apply)),
make("s1", 3, 1.0, &["t-window"], Some(Level::Understand)),
make("s1", 4, 1.0, &["lo-standalone"], Some(Level::Remember)),
];
let refs: Vec<&Response> = rows.iter().collect();
let objectives = counts_by_objective(&refs, &course);
// Three items, two credited, in one denominator.
assert_eq!(objectives["lo-binding"], (3, 2.0));
assert_eq!(objectives["lo-standalone"], (1, 1.0));
// The targets are not themselves objective rows.
assert!(!objectives.contains_key("t-kd"));
// As-tagged counts are still available for the drill-down.
let tagged = counts_by_target(&refs);
assert_eq!(tagged["t-kd"], (1, 1.0));
assert_eq!(tagged.len(), 4);
let rates = rates_by_objective(&refs, &course);
assert!((rates["lo-binding"] - 2.0 / 3.0).abs() < 1e-9);
}
#[test]
fn one_item_counts_once_toward_its_objective() {
let course = tiered_course();
// A single question tagged with two targets of the same objective.
let rows = [make(
"s1",
1,
0.0,
&["t-kd", "t-plot"],
Some(Level::Understand),
)];
let refs: Vec<&Response> = rows.iter().collect();
let objectives = counts_by_objective(&refs, &course);
assert_eq!(
objectives["lo-binding"],
(1, 0.0),
"one question is one item in the objective's denominator"
);
// Whereas as-tagged counting credits both targets, as it always has.
let tagged = counts_by_target(&refs);
assert_eq!(tagged["t-kd"], (1, 0.0));
assert_eq!(tagged["t-plot"], (1, 0.0));
}
#[test]
fn an_untiered_course_rolls_up_to_itself() {
// Adopting the second tier is optional: with no targets declared, every
// entry is an objective and the rolled-up counts equal the tagged ones.
let course: CourseFile = serde_yaml_ng::from_str(
r#"
course: { code: X, title: Y, term: Z }
learning_objectives:
lo-a: { text: A }
lo-b: { text: B }
"#,
)
.expect("course parses");
let rows = [
make("s1", 1, 1.0, &["lo-a", "lo-b"], Some(Level::Remember)),
make("s1", 2, 0.0, &["lo-a"], Some(Level::Apply)),
];
let refs: Vec<&Response> = rows.iter().collect();
assert_eq!(counts_by_objective(&refs, &course), counts_by_target(&refs));
}
#[test]
fn level_rates_ignore_untagged_items() {
let rows = [
@@ -1089,6 +1354,7 @@ mod tests {
assessment_id: "a".into(),
date: None,
form: None,
form_position: None,
student_key: student.into(),
sid: None,
name: None,
@@ -1098,17 +1364,20 @@ 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,
score: credit,
response_time_seconds: None,
level,
learning_objectives: objectives.iter().map(|s| s.to_string()).collect(),
learning_targets: objectives.iter().map(|s| s.to_string()).collect(),
topics: vec![],
bonus: false,
dropped: false,
dropped_full_credit: false,
}
}
+249
View File
@@ -0,0 +1,249 @@
/* ============================================================================
questions.css — worksheet items + gated solutions
Sits beside editor-notes.css: same left-border-accent language, same
small-caps auto-headers, same .quarto-dark dark-mode hook.
Worksheet content is authored as Quarto MARKDOWN inside fenced divs, so
inline math is $ … $ and paragraphs/lists render normally. Choice letters
(A, B, C, D) are drawn by a CSS counter, so a choice is just a list item.
Semantic color coding (information, not decoration):
indigo = a solution / answer region
green = the correct choice / accepted answer
red = a named misconception
==========================================================================*/
:root {
--q-ink: #1f2328;
--q-muted: #5a5a5a;
--q-line: #e4e4e4;
--q-card-bg: #ffffff;
--sol-accent: #4b4f9a; /* indigo: "here be answers" */
--sol-bg: #f5f5fb;
--sol-rule: #dedcef;
--ok-ink: #2f6249; /* correct / accepted (matches editorial green) */
--ok-bg: #e7f2ec;
--mis-ink: #a13d2e; /* misconception (matches editorial red) */
}
/* ---- Question card ---- */
.q {
border: 1px solid var(--q-line);
border-radius: 6px;
background: var(--q-card-bg);
padding: 1.1rem 1.3rem 1.25rem;
margin: 1.6rem 0;
}
/* Header. Authored as [Question 1]{.q-num} [..]{.q-kind} [..]{.q-points}; */
.q-head {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.7rem;
padding-bottom: 0.55rem;
border-bottom: 1px solid var(--q-line);
}
.q-head > p { display: contents; margin: 0; }
.q-num { font-weight: 700; letter-spacing: 0.01em; }
.q-kind {
font-variant: small-caps; letter-spacing: 0.06em;
font-size: 0.78rem; color: var(--q-muted);
}
.q-points {
margin-left: auto; font-size: 0.8rem; color: var(--q-muted);
white-space: nowrap;
}
.q-stem { margin: 0 0 0.9rem; }
.q-stem > p:first-child { margin-top: 0; }
.q-stem > p:last-child { margin-bottom: 0; }
/* ---- Multiple choice (a plain ordered list; letters via counter) ---- */
.q-choices > ol {
list-style: none; margin: 0; padding: 0;
display: grid; gap: 0.5rem;
counter-reset: choice;
}
.q-choices > ol > li {
position: relative;
padding: 0.55rem 0.7rem 0.55rem 3rem; /* room for the badge on the left */
border: 1px solid var(--q-line);
border-radius: 5px;
counter-increment: choice;
}
.q-choices > ol > li::before {
content: counter(choice, upper-alpha); /* A, B, C, D … */
position: absolute;
left: 0.55rem; top: 0.5rem;
display: grid; place-items: center;
width: 1.9rem; height: 1.9rem;
border: 1px solid var(--sol-accent);
border-radius: 50%;
font-weight: 700; font-size: 0.9rem;
color: var(--sol-accent);
}
/* ---- Free-response writing space (prints with room to write) ----- */
.q-response {
min-height: 6.5rem;
border: 1px dashed var(--q-line);
border-radius: 5px;
background:
repeating-linear-gradient(
to bottom, transparent, transparent 1.55rem,
var(--q-line) 1.55rem, var(--q-line) calc(1.55rem + 1px));
background-position: 0 0.9rem;
}
.q-response::before {
content: "Your answer";
display: block;
font-variant: small-caps; letter-spacing: 0.06em;
font-size: 0.72rem; color: var(--q-muted);
padding: 0.3rem 0.6rem 0;
}
/* ---- Solution slot (filled by solutions.js on unlock) ---- */
.qsol {
border-left: 3px solid var(--sol-accent);
border-radius: 0 4px 4px 0;
background: var(--sol-bg);
padding: 0.9rem 1.15rem;
margin-top: 1rem;
font-size: 0.95rem; line-height: 1.55;
}
.qsol::before {
content: "Solution";
display: block;
font-variant: small-caps; letter-spacing: 0.06em; font-weight: 600;
color: var(--sol-accent);
padding-bottom: 0.35rem; margin-bottom: 0.6rem;
border-bottom: 1px solid var(--sol-rule);
}
.qsol > p:first-of-type { margin-top: 0; }
.qsol > *:last-child { margin-bottom: 0; }
@keyframes sol-in { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; } }
.qsol.is-unlocked { animation: sol-in 180ms ease-out; }
@media (prefers-reduced-motion: reduce) { .qsol.is-unlocked { animation: none; } }
/* pieces inside a solution */
.sol-answer { font-size: 1.02rem; }
.sol-model {
border-left: 2px solid var(--ok-ink);
background: var(--ok-bg);
padding: 0.55rem 0.8rem; border-radius: 0 4px 4px 0; margin: 0.7rem 0;
}
.sol-model > p:first-child { margin-top: 0; }
.sol-model > p:last-child { margin-bottom: 0; }
.sol-explain { margin: 0.7rem 0; }
.sol-feedback-title {
font-variant: small-caps; letter-spacing: 0.05em; font-weight: 600;
color: var(--q-muted); margin: 0.9rem 0 0.4rem;
}
/* per-distractor feedback: badge in col 1, BOTH text spans in col 2 ---- */
.sol-feedback { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.6rem; }
.sol-feedback > li {
display: grid;
grid-template-columns: 1.9rem 1fr; /* badge | body */
gap: 0.6rem;
align-items: start;
}
.sol-feedback .opt {
display: grid; place-items: center; width: 1.9rem; height: 1.9rem;
border-radius: 50%; font-weight: 700; font-size: 0.8rem;
color: var(--mis-ink); border: 1px solid var(--mis-ink);
}
.sol-feedback .opt-body { /* the single col-2 cell; fills 1fr */
display: grid; gap: 0.25rem;
}
.sol-feedback .opt-mis { color: var(--mis-ink); font-style: italic; }
.sol-feedback .opt-why { color: var(--q-ink); }
/* rubric table */
.sol-rubric { width: 100%; border-collapse: collapse; margin: 0.7rem 0; font-size: 0.92rem; }
.sol-rubric caption {
text-align: left; font-variant: small-caps; letter-spacing: 0.05em;
font-weight: 600; color: var(--q-muted); padding-bottom: 0.35rem;
}
.sol-rubric th, .sol-rubric td {
border-top: 1px solid var(--sol-rule); padding: 0.4rem 0.55rem; text-align: left;
vertical-align: top;
}
.sol-rubric th:first-child, .sol-rubric td:first-child {
width: 2.5rem; text-align: center; font-weight: 700; color: var(--ok-ink);
}
.sol-accepted { margin: 0.7rem 0; }
.sol-ref { margin-top: 0.7rem; font-size: 0.85rem; color: var(--q-muted); }
/* badges */
.badge {
display: inline-block; font-variant: small-caps; letter-spacing: 0.05em;
font-size: 0.72rem; font-weight: 700; padding: 0.05em 0.5em;
border-radius: 999px; margin-right: 0.35em;
background: var(--sol-rule); color: var(--sol-accent);
}
.badge-correct { background: var(--ok-bg); color: var(--ok-ink); }
/* ---- The password gate --- */
.solutions-gate { margin: 1.4rem 0; }
.gate-inner {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem;
padding: 0.75rem 1rem;
border: 1px solid var(--sol-rule); border-left: 3px solid var(--sol-accent);
border-radius: 0 5px 5px 0; background: var(--sol-bg);
}
.gate-lock {
width: 0.8rem; height: 0.7rem; border: 2px solid var(--sol-accent);
border-radius: 2px; position: relative; flex: none;
}
.gate-lock::before {
content: ""; position: absolute; left: 50%; top: -0.42rem; transform: translateX(-50%);
width: 0.5rem; height: 0.42rem; border: 2px solid var(--sol-accent);
border-bottom: none; border-radius: 4px 4px 0 0;
}
.gate-lock.is-open::before { left: 20%; }
.gate-label { font-variant: small-caps; letter-spacing: 0.05em; font-weight: 600; color: var(--sol-accent); }
.gate-input {
flex: 1 1 12rem; min-width: 9rem;
padding: 0.4rem 0.6rem; border: 1px solid var(--sol-rule);
border-radius: 4px; background: var(--q-card-bg); color: var(--q-ink);
}
.gate-btn {
padding: 0.42rem 1rem; border: 1px solid var(--sol-accent); border-radius: 4px;
background: var(--sol-accent); color: #fff; font-weight: 600; cursor: pointer;
}
.gate-btn:hover { filter: brightness(1.08); }
.gate-btn:disabled { opacity: 0.6; cursor: progress; }
.gate-btn-ghost { background: transparent; color: var(--sol-accent); }
.gate-status { flex-basis: 100%; margin: 0; font-size: 0.85rem; color: var(--q-muted); }
.solutions-gate.is-error .gate-input { border-color: var(--mis-ink); }
.solutions-gate.is-error .gate-status { color: var(--mis-ink); }
.gate-input:focus-visible, .gate-btn:focus-visible { outline: 2px solid var(--sol-accent); outline-offset: 2px; }
@media print {
.solutions-gate { display: none; }
.qsol[hidden] { display: none; }
.q { break-inside: avoid; border-color: #bbb; }
}
/* ============================ Dark mode ================================= */
.quarto-dark {
--q-ink: #dfe2e7;
--q-muted: #a7adb6;
--q-line: #333a44;
--q-card-bg: #1b1f26;
--sol-accent: #9aa0e6;
--sol-bg: #20222e;
--sol-rule: #343755;
--ok-ink: #9dd3b4;
--ok-bg: #1b241f;
--mis-ink: #e0a498;
}
.quarto-dark .gate-btn { color: #14161c; }
+160
View File
@@ -0,0 +1,160 @@
/* ============================================================================
* solutions.js — per-page, password-gated solutions for the course site.
*
* How a page opts in:
* 1. Put one gate element somewhere on the page:
* <div class="solutions-gate" data-bundle="a1.1-solutions.json"></div>
* `data-bundle` is resolved relative to the page URL, so each page points
* at its own bundle and therefore has its own password. Nothing else is
* shared between pages.
* 2. For every gated item, leave an empty, hidden slot where the solution
* should appear:
* <div class="solution" data-solution-for="q-enthalpy-qp-001" hidden></div>
* The id in data-solution-for must match a key in the bundle's `items`.
*
* What happens on unlock:
* The typed password is run through PBKDF2 (same params as the bundle's kdf
* block) to derive an AES-256-GCM key. The first item is decrypted as a
* probe: because GCM authenticates, a wrong password throws, and we report
* "wrong password" without revealing anything. On success every slot is
* filled, un-hidden, and MathJax re-typesets the injected math.
*
* The ciphertext ships in the page, so this stops a student
* from reading answers in "View source" or the Network tab, and a wrong
* password reveals nothing. Anyone who has the password can decrypt, and the
* bundle can be brute-forced offline against a weak password. Use a real,
* non-guessable per-page password, and rotate it if a key deadline has passed.
* ==========================================================================*/
(() => {
"use strict";
const b64ToBytes = (s) =>
Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
async function deriveKey(password, kdf) {
const base = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: b64ToBytes(kdf.salt),
iterations: kdf.iterations, hash: kdf.hash },
base, { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
}
async function decryptItem(key, item) {
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: b64ToBytes(item.iv) }, key, b64ToBytes(item.ct));
return new TextDecoder().decode(pt);
}
function typeset(nodes) {
if (window.MathJax && typeof window.MathJax.typesetPromise === "function") {
window.MathJax.typesetPromise(nodes).catch(() => { /* leave as-is */ });
}
}
function buildGate(gate) {
gate.classList.add("is-locked");
gate.innerHTML = `
<div class="gate-inner">
<span class="gate-lock" aria-hidden="true"></span>
<label class="gate-label" for="gate-pw">Solutions are locked</label>
<input class="gate-input" id="gate-pw" type="password"
autocomplete="off" spellcheck="false"
placeholder="Enter the page password" />
<button class="gate-btn" type="button">Unlock</button>
<p class="gate-status" role="status" aria-live="polite"></p>
</div>`;
return {
input: gate.querySelector(".gate-input"),
button: gate.querySelector(".gate-btn"),
status: gate.querySelector(".gate-status"),
};
}
async function unlock(gate, ui) {
const url = gate.getAttribute("data-bundle");
const pw = ui.input.value;
if (!pw) { ui.input.focus(); return; }
gate.classList.remove("is-error");
ui.button.disabled = true;
ui.status.textContent = "Checking\u2026";
let bundle;
try {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`bundle ${res.status}`);
bundle = await res.json();
} catch (e) {
ui.button.disabled = false;
ui.status.textContent = "Could not load the solutions file for this page.";
return;
}
let key;
try {
key = await deriveKey(pw, bundle.kdf);
// Probe with the first item so a wrong password fails before we touch DOM.
const firstId = Object.keys(bundle.items)[0];
await decryptItem(key, bundle.items[firstId]);
} catch (e) {
gate.classList.add("is-error");
ui.button.disabled = false;
ui.status.textContent = "That password didn\u2019t work. Try again.";
ui.input.select();
return;
}
const filled = [];
for (const slot of document.querySelectorAll("[data-solution-for]")) {
const id = slot.getAttribute("data-solution-for");
const item = bundle.items[id];
if (!item) continue;
try {
slot.innerHTML = await decryptItem(key, item);
slot.hidden = false;
slot.classList.add("is-unlocked");
filled.push(slot);
} catch (e) { /* skip an item that fails; others still unlock */ }
}
typeset(filled);
gate.classList.remove("is-locked");
gate.classList.add("is-unlocked");
gate.innerHTML = `
<div class="gate-inner">
<span class="gate-lock is-open" aria-hidden="true"></span>
<span class="gate-label">Solutions unlocked</span>
<button class="gate-btn gate-btn-ghost" type="button">Hide again</button>
</div>`;
gate.querySelector(".gate-btn").addEventListener("click", () => {
for (const s of filled) { s.hidden = true; s.classList.remove("is-unlocked"); }
buildAndWire(gate); // relock the UI; content stays in memory only
});
}
function buildAndWire(gate) {
const ui = buildGate(gate);
const go = () => unlock(gate, ui);
ui.button.addEventListener("click", go);
ui.input.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); });
}
function init() {
const gate = document.querySelector(".solutions-gate[data-bundle]");
if (!gate) return;
if (!window.crypto || !crypto.subtle) {
gate.textContent =
"This browser can\u2019t decrypt solutions (no Web Crypto over http/file).";
return;
}
buildAndWire(gate);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
+283 -14
View File
@@ -261,6 +261,43 @@ fn policy_schema() -> Value {
"minimum": 1,
"description": "Below this many items on an objective, reports say 'not enough \
evidence' rather than classifying."
},
"grade_scale": {
"type": "array",
"description": "Letter-grade bands. Only the lower bound of each is recorded; a \
band runs up to the next one. Set this and a class report bins \
scores by letter rather than by ten-point interval.",
"items": {
"type": "object",
"required": ["letter", "min"],
"additionalProperties": false,
"properties": {
"letter": {
"type": "string",
"description": "The letter as it appears on a transcript."
},
"min": {
"type": "number",
"minimum": 0,
"maximum": 100,
"description": "Lowest percentage earning this letter, inclusive."
},
"gpa": {
"type": "number",
"minimum": 0,
"description": "Grade points the band carries."
},
"attainment": {
"type": "string",
"description": "The attainment word attached to the band."
},
"group": {
"type": "string",
"description": "Colour group for reports; defaults to the letter's \
first character."
}
}
}
}
}
})
@@ -291,7 +328,47 @@ 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()
}
}
})
}
/// The schema for one learning target.
fn target_schema() -> Value {
json!({
"type": "object",
"required": ["text", "objective"],
"additionalProperties": false,
"properties": {
"text": text("The target as a student would read it. Start with a verb."),
"objective": {
"type": "string",
"description": "The objective this target belongs to. Required: a target with \
no objective would be measured and never reported."
},
"lectures": string_array("Lecture ids that cover this."),
"order": {
"type": "integer",
"minimum": 1,
"description": "Position among the other targets of the same objective, low \
first. Ordered within its objective rather than across the \
course, so inserting one renumbers nothing outside its group."
},
"level_ceiling": level(),
"prerequisites": string_array(
"Target or objective ids that must come first. Cycles are rejected."
),
"tags": string_array("Free-form tags."),
"assessed": {
"type": "boolean",
"description": "Set false for something you teach but do not test. An \
unassessed objective exempts its targets regardless."
}
}
})
}
@@ -306,6 +383,12 @@ 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. Without it objectives \
sort by id, which puts one before its own prerequisite."
},
"level_ceiling": level(),
"prerequisites": string_array(
"Objective ids that must come first. Cycles are rejected."
@@ -314,12 +397,103 @@ fn objective_schema() -> Value {
"assessed": {
"type": "boolean",
"description": "Set false for an objective you teach but do not test; coverage \
reporting will stop flagging it as a gap."
reporting will stop flagging it as a gap. This exempts its \
targets too."
}
}
})
}
/// 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."
},
"targets": string_array(
"Target ids this reading serves. A student who misses one of these is pointed \
here, so the list is what makes study guidance specific. Cite targets rather \
than objectives: a section of a book backs a specific performance, and a \
reading list resolved from an objective would send a student the same six \
sections whichever part of it they missed."
),
"summary": text("What the section contains."),
"focus": text("What to take from it. This is the sentence a student report quotes."),
"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!({
@@ -365,14 +539,30 @@ fn course_schema() -> Value {
},
"learning_objectives": {
"type": "object",
"description": "Objectives by id. Everything downstream — coverage, mastery, \
student reports — keys off these.",
"description": "Learning objectives by id, conventionally `lo-...`. The tier a \
syllabus lists and a report classifies as met. Objectives only: \
the performances they are met by go in `learning_targets`.",
"additionalProperties": objective_schema()
},
"learning_targets": {
"type": "object",
"description": "Learning targets by id. Each names the objective it belongs to. \
This is the tier items are tagged to and readings are cited \
against; results roll up to the objective. Ids must not start \
with `lo`, so that an id in an item or a report says which tier \
it belongs to without a lookup — `t-...` is the convention.",
"additionalProperties": target_schema()
},
"stimuli": {
"type": "object",
"description": "Shared passages, figures, or data that several items refer to.",
"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()
}
}
})
@@ -466,6 +656,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!({
@@ -633,9 +894,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 },
@@ -662,11 +924,15 @@ 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()
},
"learning_objectives": string_array(
"Objective ids this item measures. Reports aggregate on these, so an item with none \
contributes to nothing."
"solution": solution_schema(),
"learning_targets": string_array(
"Target ids this item measures. Reports aggregate these onto the target's objective, \
so an item with none contributes to nothing. Name the target rather than the \
objective: the objective follows from it, and recording which target was asked \
about is what lets a report explain a result instead of only stating it."
),
"sources": {
"type": "array",
@@ -702,7 +968,7 @@ fn item_schema() -> Value {
}
json!({
"type": "object",
"required": ["id", "level", "stem", "options"],
"required": ["id", "level", "stem"],
"additionalProperties": false,
"properties": Value::Object(properties)
})
@@ -732,7 +998,7 @@ fn bank_scope_schema() -> Value {
outside it.",
"properties": {
"lectures": string_array("Lecture ids."),
"learning_objectives": string_array("Objective ids."),
"learning_targets": string_array("Target ids."),
"units": string_array("Unit ids."),
"topics": string_array("Topics.")
}
@@ -828,7 +1094,10 @@ fn blueprint_schema() -> Value {
"type": "object",
"description": "Minimum items per objective. Placed before level quotas, because \
a coverage requirement is the constraint most likely to become \
unsatisfiable.",
unsatisfiable. A requirement is satisfied by items on any of \
that objective's targets, so this stays short: name the dozen \
objectives the exam is meant to cover, not the hundred targets \
it is built from.",
"additionalProperties": { "type": "integer", "minimum": 0 }
},
"lectures": string_array("Restrict the draw to these lectures."),
@@ -890,7 +1159,7 @@ fn placement_schema() -> Value {
"bonus": { "type": "boolean" },
"key": string_array("Keyed option letters as administered."),
"level": level(),
"learning_objectives": string_array("Objectives as administered."),
"learning_targets": string_array("Targets as administered."),
"credit_overrides": {
"type": "object",
"description": "Partial credit decided after the fact, by option letter. Recording \
+10 -4
View File
@@ -421,7 +421,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
});
};
// ---------------------------------------------------------------- clarity
// --- clarity
let stem = it.stem.trim();
let stem_lower = stem.to_lowercase();
let words: Vec<&str> = stem.split_whitespace().collect();
@@ -513,7 +513,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
);
}
// ----------------------------------------------------------------- cueing
// --- cueing
let keys: Vec<&crate::item::Choice> = 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<Find
}
}
// ----------------------------------------------------------- completeness
// --- completeness
// These are only worth insisting on once an item is meant to be used.
let is_ready = matches!(it.status, Status::Approved | Status::InReview);
if is_ready {
@@ -749,7 +749,7 @@ pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec<Find
}
}
// --------------------------------------------------------------- evidence
// --- evidence
if !it.calibration_is_current() {
push(
Rule::StaleCalibration,
@@ -868,6 +868,12 @@ fn lint_option_counts(catalog: &Catalog) -> Vec<Finding> {
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);
}
+104 -19
View File
@@ -29,7 +29,7 @@ use std::collections::{BTreeMap, BTreeSet};
use crate::assessment::{Assessment, AssessmentFile, Blueprint, Form, Kind, Placement, Platform};
use crate::catalog::Catalog;
use crate::course::SCHEMA_VERSION;
use crate::course::{CourseFile, SCHEMA_VERSION};
use crate::date::Date;
use crate::error::{Error, Result};
use crate::history::History;
@@ -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,15 +101,25 @@ pub fn select(
let mut chosen: Vec<String> = Vec::new();
let mut per_bank: BTreeMap<String, usize> = 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 {
let mut have = 0;
// A requirement written against an objective is satisfied by items on
// any of its targets, which is the only way a coverage requirement stays
// writable: a blueprint that had to name each target separately would be
// as long as the registry, and would need editing every time an
// objective gained one.
let mut candidates: Vec<&crate::catalog::Entry> = eligible
.iter()
.copied()
.filter(|e| e.item.learning_objectives.iter().any(|o| o == objective))
.filter(|e| {
e.item
.learning_targets
.iter()
.any(|t| t == objective || catalog.course.objective_for(t) == objective)
})
.filter(|e| !e.item.bonus)
.collect();
rank(&mut candidates, history, seed, "objective");
@@ -140,7 +150,7 @@ pub fn select(
}
}
// ------------------------------------------------------- level quotas
// --- level quotas
let mut scored: Vec<String> = Vec::new();
for (level, want) in &blueprint.level_counts {
if *want == 0 {
@@ -175,7 +185,7 @@ pub fn select(
}
}
// ------------------------------------------------------------ bonus items
// --- bonus items
let mut bonus: Vec<String> = Vec::new();
for (level, want) in &blueprint.bonus_counts {
if *want == 0 {
@@ -465,9 +475,11 @@ pub fn to_record(
bonus: is_bonus || e.item.bonus,
key: e.item.key_letters(),
level: Some(e.item.level),
learning_objectives: e.item.learning_objectives.clone(),
learning_targets: e.item.learning_targets.clone(),
credit_overrides: BTreeMap::new(),
dropped: false,
dropped_as: None,
dropped_before_printing: false,
});
}
@@ -584,11 +596,14 @@ pub fn option_order(form: &Form, uid: &str, n: usize) -> Vec<usize> {
/// # Arguments
///
/// * `record` - the assessment record.
/// * `course` - the course, for the objective each tagged target belongs to. An
/// objective minimum is satisfied by items on any of that objective's
/// targets, the same way [`select`] fills it.
///
/// # Returns
///
/// One message per discrepancy, empty when the form matches the design.
pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
pub fn check_blueprint(record: &AssessmentFile, course: &CourseFile) -> Vec<String> {
let Some(bp) = &record.blueprint else {
return vec!["the record carries no blueprint to check against".into()];
};
@@ -608,7 +623,11 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
let got = record
.items
.iter()
.filter(|p| p.learning_objectives.iter().any(|o| o == objective))
.filter(|p| {
p.learning_targets
.iter()
.any(|t| t == objective || course.objective_for(t) == objective)
})
.count();
if got < *needed {
out.push(format!(
@@ -619,7 +638,7 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
out
}
/// The set of objectives an assessment covers.
/// The set of learning targets an assessment covers, as tagged.
///
/// # Arguments
///
@@ -627,12 +646,36 @@ pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
///
/// # Returns
///
/// The objective ids, deduplicated.
pub fn covered_objectives(record: &AssessmentFile) -> BTreeSet<String> {
/// The target ids, deduplicated.
pub fn covered_targets(record: &AssessmentFile) -> BTreeSet<String> {
record
.items
.iter()
.flat_map(|p| p.learning_objectives.iter().cloned())
.flat_map(|p| p.learning_targets.iter().cloned())
.collect()
}
/// The objectives an assessment covers, through the targets it measured.
///
/// The list a coverage claim should be made from: "this exam covered eleven of
/// the course's thirty-two objectives" is a sentence about the blueprint, while
/// the same count over targets is a sentence about how finely the course happens
/// to be subdivided.
///
/// # Arguments
///
/// * `record` - the assessment record.
/// * `course` - the course, for the objective each tagged target belongs to.
///
/// # Returns
///
/// The objective ids, deduplicated.
pub fn covered_objectives(record: &AssessmentFile, course: &CourseFile) -> BTreeSet<String> {
record
.items
.iter()
.flat_map(|p| p.learning_targets.iter())
.map(|t| course.objective_for(t).to_string())
.collect()
}
@@ -689,12 +732,14 @@ blueprint:
level_counts: { 1: 2, 3: 1 }
objective_minimums: { lo-key: 2 }
items:
- { number: 1, item: "b::q-1", level: 1, learning_objectives: [lo-key] }
- { number: 1, item: "b::q-1", level: 1, learning_targets: [lo-key] }
- { number: 2, item: "b::q-2", level: 1 }
"#,
)
.unwrap();
let issues = check_blueprint(&record);
let course: CourseFile =
serde_yaml_ng::from_str("course: { code: C, title: T, term: M }").unwrap();
let issues = check_blueprint(&record, &course);
assert!(issues.iter().any(|i| i.contains("level 3")), "{issues:?}");
assert!(issues.iter().any(|i| i.contains("lo-key")), "{issues:?}");
// Level 1 matches, so it must not be reported.
@@ -702,17 +747,57 @@ items:
}
#[test]
fn covered_objectives_deduplicates() {
fn an_objective_minimum_is_met_by_items_on_its_targets() {
// The blueprint names the objective a report will classify; the items
// are tagged with the specific performances they measure.
let record: AssessmentFile = serde_yaml_ng::from_str(
r#"
assessment: { id: x, title: X }
blueprint:
objective_minimums: { lo-binding: 2 }
items:
- { number: 1, item: "b::q-1", level: 1, learning_targets: [t-kd] }
- { number: 2, item: "b::q-2", level: 3, learning_targets: [t-plot] }
"#,
)
.unwrap();
let course: CourseFile = serde_yaml_ng::from_str(
r#"
course: { code: C, title: T, term: M }
learning_objectives:
lo-binding: { text: Quantify binding. }
learning_targets:
t-kd: { text: Write the expression., objective: lo-binding }
t-plot: { text: Read a plot., objective: lo-binding }
"#,
)
.unwrap();
let issues = check_blueprint(&record, &course);
assert!(
!issues.iter().any(|i| i.contains("lo-binding")),
"two items on its targets satisfy it: {issues:?}"
);
assert_eq!(
covered_objectives(&record, &course)
.into_iter()
.collect::<Vec<_>>(),
vec!["lo-binding"],
"coverage is claimed at the tier the blueprint is written in"
);
}
#[test]
fn covered_targets_deduplicates() {
let record: AssessmentFile = serde_yaml_ng::from_str(
r#"
assessment: { id: x, title: X }
items:
- { number: 1, item: "b::q-1", learning_objectives: [lo-a, lo-b] }
- { number: 2, item: "b::q-2", learning_objectives: [lo-a] }
- { number: 1, item: "b::q-1", learning_targets: [lo-a, lo-b] }
- { number: 2, item: "b::q-2", learning_targets: [lo-a] }
"#,
)
.unwrap();
let set = covered_objectives(&record);
let set = covered_targets(&record);
assert_eq!(set.len(), 2);
assert!(set.contains("lo-a"));
}
+224 -5
View File
@@ -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 target.
#[command(subcommand)]
Lecture(LectureCommand),
/// Work with item banks.
#[command(subcommand)]
Bank(BankCommand),
@@ -83,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,
}
@@ -123,6 +129,67 @@ 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. Target 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<PathBuf>,
},
/// Write the learning objectives block for one lecture, with each
/// objective's learning targets enumerated beneath it.
///
/// One block rather than two: the objective and its targets are the same
/// claim at two grains, and separate sections would print every target
/// twice. The numbering comes from the same place as the `_(T 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<PathBuf>,
},
/// Show the readings behind each learning target, and which have none.
Coverage {
/// Only this lecture's targets.
#[arg(long)]
lecture: Option<String>,
},
}
#[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,
@@ -143,7 +210,7 @@ impl SeverityArg {
#[derive(Debug, Args)]
pub(crate) struct CatalogArgs {
/// Show per-objective coverage and the gaps in it.
/// Show coverage per objective and per target, and the gaps in it.
#[arg(long)]
pub(crate) coverage: bool,
/// Show topic counts.
@@ -232,7 +299,8 @@ pub(crate) struct AssembleArgs {
/// Bonus items per level, same syntax.
#[arg(long, value_delimiter = ',')]
pub(crate) bonus: Vec<String>,
/// Minimum items per objective, e.g. --require lo-kinetics=2.
/// Minimum items per objective, e.g. --require lo-kinetics=2. Satisfied by
/// items on any of that objective's targets.
#[arg(long, value_delimiter = ',')]
pub(crate) require: Vec<String>,
/// Restrict the draw to these lectures.
@@ -309,6 +377,11 @@ pub(crate) enum ExportCommand {
/// Leave per-option feedback out of the package.
#[arg(long)]
no_feedback: bool,
/// Canvas attempt limit; -1 for unlimited. Overrides the assessment's
/// `attempts` field. More than one attempt also makes per-option feedback
/// show the hint rather than the misconception.
#[arg(long)]
attempts: Option<i64>,
},
/// Render a printable exam, answer key, and answer sheet.
///
@@ -350,6 +423,54 @@ pub(crate) enum ExportCommand {
#[arg(long)]
out: Option<PathBuf>,
},
/// 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 <file>.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<String>,
/// Output directory; defaults to build/.
#[arg(long)]
out: Option<PathBuf>,
/// Do not leave written-answer space after open-response questions.
#[arg(long)]
no_answer_space: bool,
},
/// Write a Quarto questions partial and an encrypted, password-gated
/// solutions bundle for the course website.
///
/// Needs the `site` feature (`cargo build --features site`). Writes
/// `_questions.qmd` and `<id>-solutions.json` into the output directory, and
/// prints a fresh password that the files do not store.
Site {
/// Assessment id.
id: String,
/// Which form's option order to print.
#[arg(long, default_value = "A")]
form: String,
/// Output directory; defaults to build/. Point it at the page's own
/// directory so the browser fetches the bundle beside the page.
#[arg(long)]
out: Option<PathBuf>,
/// Encrypt with this password instead of a generated one. Use only to
/// re-encrypt a page with a known password.
#[arg(long)]
password: Option<String>,
/// Also write questions.css and solutions.js into this directory, e.g.
/// the site's static assets folder. They install once, not per page.
#[arg(long, value_name = "DIR")]
assets: Option<PathBuf>,
},
}
#[derive(Debug, Subcommand)]
@@ -436,12 +557,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<String>,
#[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 {
@@ -533,6 +665,60 @@ 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,
/// Leave out the hint written for the option the student chose.
#[arg(long)]
no_hints: bool,
/// Leave the "kinds of thinking" comparison out of the Typst report.
#[arg(long)]
no_levels: bool,
/// Leave the per-objective mastery table out of the Typst report.
#[arg(long)]
no_objectives: bool,
/// Leave the objectives called out as strengths out of the Typst report.
#[arg(long)]
no_strengths: bool,
/// Leave the objectives called out as focus areas out of the Typst report.
#[arg(long)]
no_focus: bool,
/// Leave the dropped-question notice out of the Typst report.
#[arg(long)]
no_dropped_questions: bool,
/// Leave lecture-review suggestions out of the Typst report.
#[arg(long)]
no_review_lectures: bool,
/// Leave suggested study groups and readings out of the Typst report.
#[arg(long)]
no_study: bool,
/// Name the misconception each chosen distractor was written to catch.
///
/// Written for you rather than for them, so it reads clinically next to
/// the feedback. Off by default for that reason, not for secrecy.
#[arg(long)]
misconceptions: bool,
/// Include the worked solution for every missed question.
///
/// This is the solutions document. Reasonable for a question you will
/// not use again, and a way to publish your bank if you reuse it.
#[arg(long)]
solutions: bool,
/// Use this template instead of the usual lookup.
#[arg(long)]
template: Option<PathBuf>,
/// Also write each payload as JSON.
#[arg(long)]
json: bool,
},
/// The instructor's item analysis.
Cohort {
@@ -544,9 +730,42 @@ pub(crate) enum ReportCommand {
/// Output path; defaults to reports/<id>-cohort.md.
#[arg(long)]
out: Option<PathBuf>,
/// 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<PathBuf>,
/// 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<String>,
/// Store only fingerprints, not the stem and option text.
#[arg(long)]
pub(crate) no_content: bool,
/// Output path; defaults to seals/<id>.yaml.
#[arg(long)]
pub(crate) out: Option<PathBuf>,
}
#[cfg(test)]
mod tests {
use super::*;
+5
View File
@@ -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<Outcome> {
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),
@@ -66,6 +70,7 @@ pub(crate) fn run(cli: &Cli) -> Result<Outcome> {
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),
}
}
+441 -42
View File
@@ -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<Outcome> {
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<Outcome> {
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<Outcome> {
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)
@@ -239,7 +426,7 @@ pub(crate) fn analyze(cli: &Cli, sub: &AnalyzeCommand) -> Result<Outcome> {
println!(
" {:>4.0}% {}",
rate * 100.0,
catalog.course.objective_text(objective)
catalog.course.text_for(objective)
);
}
}
@@ -290,7 +477,11 @@ pub(crate) fn calibrate(cli: &Cli, args: &CalibrateArgs) -> Result<Outcome> {
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<Outcome> {
let catalog = load(cli)?;
let store = Store::open(catalog.layout.data())?;
@@ -302,6 +493,22 @@ pub(crate) fn report(cli: &Cli, sub: &ReportCommand) -> Result<Outcome> {
out,
ability,
no_comparison,
typst: want_typst,
no_markdown,
no_questions,
no_feedback,
no_hints,
no_levels,
no_objectives,
no_strengths,
no_focus,
no_dropped_questions,
no_review_lectures,
no_study,
misconceptions,
solutions,
template,
json,
} => {
let record = load_record(&catalog, id)?;
let set = responses_for(&store, &catalog, &record, false)?;
@@ -311,26 +518,146 @@ pub(crate) fn report(cli: &Cli, sub: &ReportCommand) -> Result<Outcome> {
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 mut config = typst::load_config(&catalog.layout)?.resolve(Variant::StudentReport);
// Each flag only turns a section off; leaving it unset keeps whatever
// templates/typst.yaml already resolved to, so a CLI run that doesn't
// mention a section never overrides a course's saved preference.
if *no_levels {
config.student_sections.levels = false;
}
if *no_objectives {
config.student_sections.objectives = false;
}
if *no_strengths {
config.student_sections.strengths = false;
}
if *no_focus {
config.student_sections.focus = false;
}
if *no_dropped_questions {
config.student_sections.dropped_questions = false;
}
if *no_review_lectures {
config.student_sections.review_lectures = false;
}
if *no_study {
config.student_sections.study = false;
}
let meta = typst_diagnostic::Meta::new(&catalog, &record, cohort.students.len());
let opts = diagnostic::Options {
comparison: !no_comparison,
questions: !no_questions,
feedback: !no_feedback,
hints: !no_hints,
misconceptions: *misconceptions,
solutions: *solutions,
ability: *ability,
..diagnostic::Options::default()
};
// Said once, at the moment it would matter, rather than left in the
// help text where nobody reads it twice.
if *solutions && !cli.quiet {
println!(
"! --solutions puts the worked solution for every missed question into \
{} report(s). Reusing these items next term means reusing them against \
students who may have seen this page",
cohort.students.len()
);
}
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 +665,58 @@ pub(crate) fn report(cli: &Cli, sub: &ReportCommand) -> Result<Outcome> {
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 +749,37 @@ pub(crate) fn data(cli: &Cli) -> Result<Outcome> {
}
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);
}
}
}
+1 -1
View File
@@ -184,7 +184,7 @@ pub(crate) fn assemble(cli: &Cli, args: &AssembleArgs) -> Result<Outcome> {
print_record(&catalog, &record);
let drift = select::check_blueprint(&record);
let drift = select::check_blueprint(&record, &catalog.course);
if !drift.is_empty() {
println!("\nBlueprint not fully satisfied:");
for d in &drift {
+136 -5
View File
@@ -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;
@@ -31,18 +32,31 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
form,
out,
no_feedback,
attempts: _,
} => {
let record = load_record(&catalog, id)?;
let form = pick_form(&record, form)?;
let opts = qti::QtiOptions {
form: form.clone(),
include_feedback: !no_feedback,
shuffle_in_canvas: record.assessment.shuffle.unwrap_or(false),
attempts: record.assessment.attempts.unwrap_or(1),
shuffle_in_canvas: record.assessment.shuffle.unwrap_or(true),
// No per-assessment attempts value falls back to unlimited, the
// same default `QtiOptions::default()` carries. Keeping this in
// step with the struct default avoids a record without an
// `attempts:` silently becoming single-attempt here while the
// library considers the default to be unlimited.
attempts: record.assessment.attempts.unwrap_or(-1),
scoring_policy: record
.assessment
.scoring_policy
.unwrap_or(coursebank::assessment::ScoringPolicy::KeepHighest),
// The remaining fields drive `assessment_meta.xml` (quiz type,
// results visibility, correct-answer display, one-question-at-a-
// time, timing, publish state). This command exposes no flags for
// them yet, so take the library defaults: a formative graded quiz
// that lets students review responses and the correct answer, and
// imports unpublished.
..qti::QtiOptions::default()
};
let package = qti::build(&catalog, &record, &opts)?;
let path = out
@@ -168,10 +182,119 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
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 <file>.qmd");
}
Ok(Outcome::Ok)
}
ExportCommand::Site {
id,
form,
out,
password,
assets,
} => {
{
use coursebank::site;
let record = load_record(&catalog, id)?;
let form = pick_form(&record, form)?;
let dir = out.clone().unwrap_or(build);
std::fs::create_dir_all(&dir)?;
let rendered = site::render(
&catalog,
&record,
site::Options {
form,
password: password.clone(),
},
)?;
let qmd = dir.join("_questions.qmd");
yaml::write_text(&qmd, &rendered.questions_qmd)?;
let json = dir.join(format!("{}-solutions.json", rendered.page));
yaml::write_text(&json, &rendered.solutions_json)?;
if let Some(asset_dir) = assets {
std::fs::create_dir_all(asset_dir)?;
for (name, body) in site::assets() {
let path = asset_dir.join(name);
yaml::write_text(&path, body)?;
if !cli.quiet {
println!("wrote {}", path.display());
}
}
}
// The password cannot be recovered from the files; always print it.
println!("password for {}: {}", rendered.page, rendered.password);
if !cli.quiet {
println!("wrote {}", qmd.display());
println!("wrote {}", json.display());
println!("\nInclude in the page with: {{{{< include _questions.qmd >}}}}");
}
Ok(Outcome::Ok)
}
}
}
}
/// Resolves the `--variant` flags, defaulting to every document.
/// 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<Vec<practice::Variant>> {
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 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
///
@@ -187,7 +310,7 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
/// Returns [`Error::Usage`] naming the valid tokens.
fn pick_variants(names: &[String]) -> Result<Vec<typst::Variant>> {
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 {
@@ -265,7 +388,15 @@ pub(crate) fn template(cli: &Cli, sub: &TemplateCommand) -> Result<Outcome> {
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() {
+558
View File
@@ -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<Outcome> {
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<Outcome> {
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<Outcome> {
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<String, usize> {
let mut seen: BTreeMap<String, std::collections::BTreeSet<&str>> = 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()
}
+110
View File
@@ -0,0 +1,110 @@
// 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 learning target.
//!
//! 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<Outcome> {
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<Outcome> {
match out {
Some(path) => {
yaml::write_text(path, &markdown)?;
println!("wrote {}", path.display());
}
None => print!("{markdown}"),
}
Ok(Outcome::Ok)
}
/// Prints the readings behind each learning target.
///
/// Targets rather than objectives, because that is the tier a reading is cited
/// against: a section of a book backs a specific performance. Returns
/// [`Outcome::Findings`] when an assessed target 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<Outcome> {
let ids: Vec<String> = match lecture {
Some(l) => course
.lecture_targets(l)
.into_iter()
.map(str::to_string)
.collect(),
None => course.targets_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, |id| { course.text_for(id) })
);
}
}
}
let gaps = course.targets_without_readings();
if gaps.is_empty() {
if !quiet {
println!("\nevery assessed target has a reading behind it");
}
return Ok(Outcome::Ok);
}
println!(
"\n{} assessed target(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)
}
+244 -8
View File
@@ -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;
@@ -18,7 +20,7 @@ use coursebank::error::{Error, Result};
use coursebank::jsonschema;
use coursebank::layout::Layout;
use coursebank::lint::{self, Rule};
use coursebank::taxonomy::Level;
use coursebank::taxonomy::{Level, Tier};
use coursebank::yaml;
use crate::cli::{CatalogArgs, Cli, InitArgs, LintArgs};
@@ -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<Outcome> {
let layout = Layout::new(&cli.course);
@@ -70,15 +75,162 @@ pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result<Outcome> {
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 \
"\nNext: edit {} to add your learning objectives, their targets, and your\n lectures, then\n \
coursebank bank new unit-1 --title \"Unit 1\"\n coursebank validate",
COURSE_FILE
);
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<String>,
/// 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<String>,
}
/// 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<String> {
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<String> = 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<String> = Vec::new();
let mut negated: Vec<String> = 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`, 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, the way this always has.
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<Outcome> {
let layout = Layout::new(&cli.course);
@@ -235,15 +387,29 @@ pub(crate) fn catalog(cli: &Cli, args: &CatalogArgs) -> Result<Outcome> {
let coverage = catalog.coverage();
println!("\nObjective coverage:");
println!(
" {:<40} {:>6} {:>6} MAX LEVEL",
"OBJECTIVE", "ITEMS", "READY"
" {:<44} {:>6} {:>6} {:>7} MAX LEVEL",
"OBJECTIVE / TARGET", "ITEMS", "READY", "TARGETS"
);
for row in &coverage.rows {
// Objectives carry the totals and are the tier a blueprint is
// written at, so they are the rows to scan; the indented target rows
// say where inside each one the items sit.
let label = if row.tier == Tier::Objective {
truncate(&row.id, 44)
} else {
truncate(&format!(" - {}", row.id), 44)
};
let targets = if row.targets > 0 {
format!("{}/{}", row.targets_covered, row.targets)
} else {
"-".to_string()
};
println!(
" {:<40} {:>6} {:>6} {}",
truncate(&row.objective, 40),
" {:<44} {:>6} {:>6} {:>7} {}",
label,
row.total,
row.assemblable,
targets,
row.max_level
.map(|l| l.code().to_string())
.unwrap_or_else(|| "-".into())
@@ -271,4 +437,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 -1
View File
@@ -26,8 +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;
#[cfg(feature = "parquet")]
pub mod store_parquet;
+8 -1
View File
@@ -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,17 +338,20 @@ 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,
score,
response_time_seconds: None,
level: None,
learning_objectives: Vec::new(),
learning_targets: Vec::new(),
topics: Vec::new(),
bonus: false,
dropped: false,
dropped_full_credit: false,
});
}
}
+847
View File
@@ -0,0 +1,847 @@
// 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,
}
/// The form marker put on a row that could not be translated, so it can be
/// removed after the borrow on `set.rows` ends. No real form id can collide
/// with it: form ids come from the record and are short labels like `A`.
const UNMAPPED: &str = "\u{1f}unmapped";
/// 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<String>,
/// Keyed letters in the bank's own lettering.
pub canonical_key: Vec<String>,
/// Printed letter to bank letter.
pub to_canonical: BTreeMap<String, String>,
/// Bank letter to printed letter.
pub to_printed: BTreeMap<String, String>,
}
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<u32, QuestionMap>,
/// Questions by recorded number.
by_number: BTreeMap<u32, QuestionMap>,
}
impl FormDecoder {
/// Builds a decoder from the question maps.
fn assemble(form: String, provenance: Provenance, maps: Vec<QuestionMap>) -> 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<FormDecoder> {
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::<Vec<_>>()
.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<String> = 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<FormDecoder> {
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<String> = 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<FormDecoder> {
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()
}
/// Every recorded question number this form carries.
pub fn numbers(&self) -> impl Iterator<Item = u32> + '_ {
self.by_position.values().map(|q| q.number)
}
/// 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<u32>,
}
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<String> = question.keyed().into_iter().collect();
if graded.is_empty() {
continue;
}
let expected: BTreeSet<String> = 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<FormFit> {
let mut fits: Vec<FormFit> = 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<String> {
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<String> = 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<String> {
let mut warnings = Vec::new();
let mut unmapped_positions: BTreeSet<u32> = BTreeSet::new();
let mut colliding_positions: BTreeSet<u32> = BTreeSet::new();
let mut unmapped_letters: BTreeSet<String> = BTreeSet::new();
let mut translated = 0usize;
// Numbers this form really uses. An untranslated row whose raw number is one
// of these would silently masquerade as that question, and two rows would
// then share a number: one the student's answer to it, one an answer to
// something else entirely. Nothing downstream can tell them apart, so the
// collision has to be caught here.
let recorded: BTreeSet<u32> = decoder.numbers().collect();
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);
if recorded.contains(&row.item_number) {
colliding_positions.insert(row.item_number);
// Marked so the row can be discarded below. Attributing it to
// the question that legitimately holds this number would corrupt
// that question's statistics.
row.form = Some(UNMAPPED.to_string());
}
continue;
};
row.form_position = Some(map.position);
row.item_number = map.number;
let mut convert = |letters: &[String]| -> Vec<String> {
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<String> = unmapped_positions.iter().map(|n| n.to_string()).collect();
warnings.push(format!(
"form {}: question(s) {} are in the export but not on this form ({} printed). The \
export may have been taken before a question was dropped, or from a different \
form.",
decoder.form,
list.join(", "),
decoder.len()
));
}
if !colliding_positions.is_empty() {
let list: Vec<String> = colliding_positions.iter().map(|n| n.to_string()).collect();
let discarded = set.rows.len();
set.rows.retain(|row| row.form.as_deref() != Some(UNMAPPED));
warnings.push(format!(
"form {}: {} response(s) at position(s) {} could not be translated, and their raw \
numbers are numbers this form does use. Keeping them would have given those \
questions two different answers each, so they were discarded. This is the shape of \
an export made before a question was dropped: re-export the responses from the \
administration you sealed, or re-run with --recorded-numbers if the export already \
carries recorded numbers.",
decoder.form,
discarded - set.rows.len(),
list.join(", ")
));
}
if !unmapped_letters.is_empty() {
let list: Vec<String> = 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_targets: Vec::new(),
topics: Vec::new(),
bonus: false,
dropped: false,
dropped_full_credit: 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}");
}
}
+8 -1
View File
@@ -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,17 +646,20 @@ 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,
score: row.score,
response_time_seconds: None,
level: None,
learning_objectives: Vec::new(),
learning_targets: Vec::new(),
topics: Vec::new(),
bonus,
dropped: false,
dropped_full_credit: false,
});
}
}
+565
View File
@@ -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<Source> {
// 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<Date>,
/// 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<FormFit>,
/// The parsed question files, kept so grading-time decisions stay available.
pub parsed: Vec<Question>,
}
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<FormIntake>,
/// Problems that did not stop the ingest.
pub warnings: Vec<String>,
}
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<Intake> {
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<String> = 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::<Vec<_>>()
.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<FormDecoder> = 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<Form> {
if record.forms.is_empty() {
vec![Form {
id: "A".to_string(),
seed: 0,
shuffle_items: false,
shuffle_options: false,
}]
} else {
record.forms.clone()
}
}
/// Checks that only merging several forms can make.
fn cross_form_checks(
merged: &ResponseSet,
forms: &[FormIntake],
record: &AssessmentFile,
) -> Vec<String> {
let mut out = Vec::new();
if forms.len() < 2 {
return out;
}
// A student on two forms sat one exam and was graded twice, or two people
// share an identifier. Either way the response set now double counts them.
let mut by_student: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for row in &merged.rows {
if let Some(form) = row.form.as_deref() {
by_student
.entry(row.student_key.as_str())
.or_default()
.insert(form);
}
}
let doubled: Vec<&str> = by_student
.iter()
.filter(|(_, forms)| forms.len() > 1)
.map(|(student, _)| *student)
.collect();
if !doubled.is_empty() {
out.push(format!(
"{} student(s) appear on more than one form ({}); each is counted twice in every \
total until one submission is removed",
doubled.len(),
doubled
.iter()
.take(5)
.copied()
.collect::<Vec<_>>()
.join(", ")
));
}
// Every form is the same items in a different order, so a question present on
// one and absent from another means a directory is short a file.
let expected: BTreeSet<u32> = record
.items
.iter()
.filter(|p| !p.dropped)
.map(|p| p.number)
.collect();
for form in forms {
let present: BTreeSet<u32> = merged
.rows
.iter()
.filter(|r| {
r.form
.as_deref()
.map(|f| f.eq_ignore_ascii_case(&form.form))
.unwrap_or(false)
})
.map(|r| r.item_number)
.collect();
let missing: Vec<String> = expected
.difference(&present)
.map(|n| n.to_string())
.collect();
if !missing.is_empty() {
out.push(format!(
"form {}: no responses for question(s) {}; the export directory is missing those \
files",
form.form,
missing.join(", ")
));
}
}
// Forms are meant to be the same test. A large gap between their means is
// either a permutation that made one form easier or an uneven split of the
// class, and both are worth knowing before any grade is released.
let means: Vec<(String, f64, usize)> = forms
.iter()
.map(|f| {
let students: Vec<&str> = merged
.rows
.iter()
.filter(|r| {
r.form
.as_deref()
.map(|x| x.eq_ignore_ascii_case(&f.form))
.unwrap_or(false)
})
.map(|r| r.student_key.as_str())
.collect::<BTreeSet<&str>>()
.into_iter()
.collect();
let possible = merged.points_available();
let percents: Vec<f64> = students
.iter()
.map(|s| {
if possible > 0.0 {
100.0 * merged.scored_total(s) / possible
} else {
0.0
}
})
.collect();
let mean = if percents.is_empty() {
0.0
} else {
percents.iter().sum::<f64>() / percents.len() as f64
};
(f.form.clone(), mean, percents.len())
})
.collect();
if let (Some(low), Some(high)) = (
means
.iter()
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)),
means
.iter()
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)),
) {
let gap = high.1 - low.1;
if gap >= 5.0 && low.2 >= 5 && high.2 >= 5 {
out.push(format!(
"form {} averaged {:.0}% and form {} averaged {:.0}%, a {:.0}-point gap. With {} \
and {} students that may be the split rather than the forms, but it is worth \
looking at before releasing grades",
high.0, high.1, low.0, low.1, gap, high.2, low.2
));
}
}
out
}
/// A one-line summary of where a directory came from and what it held.
///
/// # Arguments
///
/// * `intake` - one form's intake.
///
/// # Returns
///
/// The line, without a trailing newline.
pub fn describe(intake: &FormIntake) -> String {
let fit = match intake.own_fit() {
Some(fit) if fit.compared > 0 => {
format!(", keys matched {}/{}", fit.matched, fit.compared)
}
_ => String::new(),
};
format!(
"form {}: {} student(s) x {} question(s) from {} (mapping {}{fit})",
intake.form,
intake.students,
intake.questions,
intake.dir.display(),
intake.provenance.label(),
)
}
/// Whether a path looks like a Gradescope per-question export directory.
///
/// Used to give a better error than "no numbered CSV files" when someone points
/// at the zip they downloaded, or at the directory above the one they meant.
///
/// # Arguments
///
/// * `dir` - the candidate directory.
///
/// # Returns
///
/// `true` when it holds at least one file named like `1.csv`.
pub fn looks_like_export(dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries.filter_map(|e| e.ok()).any(|entry| {
let path = entry.path();
path.extension().and_then(|e| e.to_str()) == Some("csv")
&& path
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or(false)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_labelled_source_parses() {
let source = Source::parse("B=exports/e1-b", None).unwrap();
assert_eq!(source.form, "B");
assert_eq!(source.dir, PathBuf::from("exports/e1-b"));
}
#[test]
fn a_bare_path_needs_a_fallback_form() {
let source = Source::parse("exports/e1", Some("A")).unwrap();
assert_eq!(source.form, "A");
assert_eq!(source.dir, PathBuf::from("exports/e1"));
let err = Source::parse("exports/e1", None).unwrap_err();
assert!(err.to_string().contains("which form"), "{err}");
}
#[test]
fn a_windows_style_path_is_not_mistaken_for_a_label() {
// The split is on the first `=`, and a drive letter has none, so this is
// only a hazard for a path that genuinely contains one.
let source = Source::parse("A=C:/exports/e1-a", None).unwrap();
assert_eq!(source.form, "A");
assert_eq!(source.dir, PathBuf::from("C:/exports/e1-a"));
}
#[test]
fn an_empty_label_is_refused() {
let err = Source::parse("=exports/e1", None).unwrap_err();
assert!(err.to_string().contains("empty form label"), "{err}");
}
}
+128 -21
View File
@@ -52,6 +52,9 @@ pub struct Response {
pub date: Option<Date>,
/// Which form the student took, when forms were used.
pub form: Option<String>,
/// Where this question sat on the student's own form, when that differs from
/// the recorded number. Provenance, not a join key.
pub form_position: Option<u32>,
/// The identifier analysis groups by. A pseudonym when pseudonymizing.
pub student_key: String,
@@ -74,8 +77,14 @@ pub struct Response {
/// Option letters the student chose.
pub selected: Vec<String>,
/// The selected options in the bank's own lettering, written at ingest by
/// [`crate::decode::apply`]. Empty when the form was never decoded, which is
/// the case for data ingested before seals existed.
pub selected_source: Vec<String>,
/// Option letters the student eliminated, for elimination-scored items.
pub eliminated: Vec<String>,
/// The eliminated options in the bank's lettering.
pub eliminated_source: Vec<String>,
/// Whether the response earned full credit. `None` when it cannot be
/// determined, e.g. a blank response on an item with no recorded key.
pub correct: Option<bool>,
@@ -92,17 +101,33 @@ pub struct Response {
/// The item's level, denormalized so analysis need not carry the catalog.
pub level: Option<Level>,
/// The item's learning objectives, denormalized for per-objective mastery.
pub learning_objectives: Vec<String>,
pub learning_targets: Vec<String>,
/// The item's topics, denormalized.
pub topics: Vec<String>,
/// Whether the item was bonus, and so excluded from the scored total.
pub bonus: bool,
/// Whether the item was dropped after the fact.
pub dropped: bool,
/// Whether that drop was applied by crediting every option, so the item is
/// still part of the points of record even though it is out of the
/// statistics.
///
/// Written by [`ResponseSet::enrich`] from the placement's `dropped_as`.
#[serde(default)]
pub dropped_full_credit: bool,
}
impl Response {
/// Whether this row should count toward scored totals and item statistics.
/// Whether this row counts as evidence.
///
/// Evidence means item statistics, objective mastery, level rates, and the
/// IRT fit. A dropped item is never evidence, however the drop was applied:
/// an item everyone was given has no variance to contribute and would only
/// flatter the objective it was written against.
///
/// This is deliberately not the same question as [`Response::scores`]. The
/// two were one predicate until dropping a question stopped always meaning
/// removing it.
///
/// # Returns
///
@@ -111,6 +136,21 @@ impl Response {
!self.bonus && !self.dropped
}
/// Whether this row counts toward the points of record.
///
/// A question dropped by crediting every option still sits in the student's
/// total on the platform, and a report that disagreed with the platform
/// about a student's percentage would be worse than no report. So a
/// full-credit drop stays in both the numerator and the denominator here,
/// while a removed drop leaves both.
///
/// # Returns
///
/// `true` when the row belongs in the score.
pub fn scores(&self) -> bool {
!self.bonus && (!self.dropped || self.dropped_full_credit)
}
/// The response coded for a dichotomous model.
///
/// Partial credit is rounded toward the majority: a half-credit response is
@@ -132,6 +172,19 @@ impl Response {
pub fn selected_joined(&self) -> String {
self.selected.join(",")
}
/// The choices, in the bank's lettering when it is known.
///
/// `selected` is what the student marked on the page they held; this is what
/// they chose. On an unshuffled form the two agree, which is why reading the
/// wrong one is a bug that only appears once you add a second form.
pub fn chosen(&self) -> &[String] {
if self.selected_source.is_empty() {
&self.selected
} else {
&self.selected_source
}
}
}
/// A set of responses plus anything worth telling the user about the ingest.
@@ -166,7 +219,11 @@ impl ResponseSet {
set.into_iter().map(|s| s.to_string()).collect()
}
/// The distinct item numbers that count toward the scored total, sorted.
/// The distinct item numbers that count as evidence, sorted.
///
/// Named for the scored total it once described; it is the analysis matrix's
/// item list, so it uses [`Response::counts`] and excludes every dropped
/// item. [`ResponseSet::points_available`] is the scoring denominator.
pub fn scored_items(&self) -> Vec<u32> {
let set: BTreeSet<u32> = self
.rows
@@ -225,11 +282,12 @@ impl ResponseSet {
///
/// # Returns
///
/// The sum of `score` over scored, undropped items.
/// The sum of `score` over the items that count toward the score, which
/// includes a question dropped by crediting every option.
pub fn scored_total(&self, key: &str) -> f64 {
self.rows
.iter()
.filter(|r| r.student_key == key && r.counts())
.filter(|r| r.student_key == key && r.scores())
.map(|r| r.score)
.sum()
}
@@ -247,7 +305,7 @@ impl ResponseSet {
/// for each item so a student who skipped an item still has a denominator.
pub fn points_available(&self) -> f64 {
let mut per_item: BTreeMap<u32, f64> = BTreeMap::new();
for r in self.rows.iter().filter(|r| r.counts()) {
for r in self.rows.iter().filter(|r| r.scores()) {
let e = per_item.entry(r.item_number).or_insert(0.0);
if r.points_possible > *e {
*e = r.points_possible;
@@ -360,6 +418,7 @@ impl ResponseSet {
r.item_version = p.version;
r.bonus = r.bonus || p.bonus;
r.dropped = r.dropped || p.dropped;
r.dropped_full_credit = r.dropped_full_credit || p.dropped_with_credit();
if let Some(points) = p.points {
// The record is authoritative for points as administered; the
// export sometimes carries a stale maximum.
@@ -376,10 +435,10 @@ impl ResponseSet {
if let Some(cat) = catalog {
if let Some(entry) = cat.get(&p.item) {
r.level = Some(entry.item.level);
r.learning_objectives = if p.learning_objectives.is_empty() {
entry.item.learning_objectives.clone()
r.learning_targets = if p.learning_targets.is_empty() {
entry.item.learning_targets.clone()
} else {
p.learning_objectives.clone()
p.learning_targets.clone()
};
r.topics = entry.item.topics.clone();
if r.points_possible == 0.0 && !p.bonus {
@@ -388,17 +447,28 @@ impl ResponseSet {
}
} else {
r.level = p.level;
r.learning_objectives = p.learning_objectives.clone();
r.learning_targets = p.learning_targets.clone();
}
// Apply the record's credit overrides, which is how a decision to
// award partial credit after the fact becomes visible in analysis.
if !p.credit_overrides.is_empty() && r.selected.len() == 1 {
if let Some(over) = p.credit_overrides.get(&r.selected[0]) {
if (*over - r.credit).abs() > 1e-9 {
r.credit = *over;
r.score = *over * r.points_possible;
r.correct = Some(*over >= 0.999);
//
// The override map is keyed in the bank's letters, so the lookup has
// to use `chosen()` rather than `selected`. Cloning the letter first
// ends the borrow of `r` before the row is written to.
let chosen = if r.chosen().len() == 1 {
r.chosen().first().cloned()
} else {
None
};
if !p.credit_overrides.is_empty() {
if let Some(letter) = chosen {
if let Some(over) = p.credit_overrides.get(&letter) {
if (*over - r.credit).abs() > 1e-9 {
r.credit = *over;
r.score = *over * r.points_possible;
r.correct = Some(*over >= 0.999);
}
}
}
}
@@ -582,14 +652,35 @@ pub struct FlatResponse {
pub response_time_seconds: String,
/// The level code 1..5, 0 when unknown.
pub level: u8,
/// Comma-joined objective ids.
pub learning_objectives: String,
/// Comma-joined target ids.
///
/// The column keeps its original name. Renaming a column in a store that
/// already holds collected administrations would make last term's responses
/// unreadable, and no vocabulary improvement is worth that.
#[serde(rename = "learning_objectives")]
pub learning_targets: String,
/// Comma-joined topics.
pub topics: String,
/// Whether the item was bonus.
pub bonus: bool,
/// Whether the item was dropped.
pub dropped: bool,
// Appended rather than interleaved: the columns above are the order every
// CSV written so far uses, and `#[serde(default)]` is what keeps one written
// last term readable now that three more exist.
/// The printed position, 0 when unknown.
#[serde(default)]
pub form_position: u32,
/// Comma-joined selected letters in the bank's lettering.
#[serde(default)]
pub selected_source: String,
/// Comma-joined eliminated letters in the bank's lettering.
#[serde(default)]
pub eliminated_source: String,
/// Whether a dropped item was dropped by crediting every option.
#[serde(default)]
pub dropped_full_credit: bool,
}
impl FlatResponse {
@@ -610,6 +701,8 @@ impl FlatResponse {
assessment_id: r.assessment_id.clone(),
date: r.date.map(|d| d.to_string()).unwrap_or_default(),
form: r.form.clone().unwrap_or_default(),
dropped_full_credit: r.dropped_full_credit,
form_position: r.form_position.unwrap_or(0),
student_key: r.student_key.clone(),
sid: r.sid.clone().unwrap_or_default(),
email: r.email.clone().unwrap_or_default(),
@@ -618,7 +711,9 @@ impl FlatResponse {
item_ref: r.item_ref.clone().unwrap_or_default(),
item_version: r.item_version.unwrap_or(0),
selected: r.selected.join(","),
selected_source: r.selected_source.join(","),
eliminated: r.eliminated.join(","),
eliminated_source: r.eliminated_source.join(","),
correct: match r.correct {
Some(true) => "1".to_string(),
Some(false) => "0".to_string(),
@@ -632,7 +727,7 @@ impl FlatResponse {
.map(|s| format!("{s:.1}"))
.unwrap_or_default(),
level: r.level.map(|l| l.code()).unwrap_or(0),
learning_objectives: r.learning_objectives.join(","),
learning_targets: r.learning_targets.join(","),
topics: r.topics.join(","),
bonus: r.bonus,
dropped: r.dropped,
@@ -660,6 +755,12 @@ impl FlatResponse {
assessment_id: self.assessment_id.clone(),
date: self.date.parse().ok(),
form: none_if_empty(&self.form),
dropped_full_credit: self.dropped_full_credit,
form_position: if self.form_position == 0 {
None
} else {
Some(self.form_position)
},
student_key: self.student_key.clone(),
sid: none_if_empty(&self.sid),
name: None,
@@ -673,7 +774,9 @@ impl FlatResponse {
Some(self.item_version)
},
selected: split(&self.selected),
selected_source: split(&self.selected_source),
eliminated: split(&self.eliminated),
eliminated_source: split(&self.eliminated_source),
correct: match self.correct.as_str() {
"1" | "true" => Some(true),
"0" | "false" => Some(false),
@@ -684,7 +787,7 @@ impl FlatResponse {
score: self.score,
response_time_seconds: self.response_time_seconds.parse().ok(),
level: Level::from_code(self.level),
learning_objectives: split(&self.learning_objectives),
learning_targets: split(&self.learning_targets),
topics: split(&self.topics),
bonus: self.bonus,
dropped: self.dropped,
@@ -713,6 +816,7 @@ mod tests {
assessment_id: "e1".into(),
date: None,
form: None,
form_position: None,
student_key: student.into(),
sid: Some(format!("sid-{student}")),
name: None,
@@ -722,17 +826,20 @@ 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: 2.0,
score: credit * 2.0,
response_time_seconds: None,
level: None,
learning_objectives: vec![],
learning_targets: vec![],
topics: vec![],
bonus: false,
dropped: false,
dropped_full_credit: false,
}
}
+8 -33
View File
@@ -49,21 +49,14 @@ impl Format {
///
/// Parquet when the `parquet` feature is on, CSV otherwise.
pub fn preferred() -> Format {
#[cfg(feature = "parquet")]
{
Format::Parquet
}
#[cfg(not(feature = "parquet"))]
{
Format::Csv
}
Format::Parquet
}
/// Whether this format can be written by the current build.
pub fn is_available(self) -> bool {
match self {
Format::Csv => true,
Format::Parquet => cfg!(feature = "parquet"),
Format::Parquet => true,
}
}
@@ -386,21 +379,10 @@ fn read_csv(path: &Path) -> Result<ResponseSet> {
///
/// * `path` - the destination.
/// * `rows` - the rows.
///
/// # Errors
///
/// Returns [`Error::FeatureDisabled`] when the feature is off.
#[cfg(feature = "parquet")]
fn write_parquet(path: &Path, rows: &[FlatResponse]) -> Result<()> {
crate::store_parquet::write(path, rows)
}
/// Stub for builds without Parquet support.
#[cfg(not(feature = "parquet"))]
fn write_parquet(_path: &Path, _rows: &[FlatResponse]) -> Result<()> {
Err(Error::FeatureDisabled("Parquet", "parquet"))
}
/// Reads flat responses from Parquet.
///
/// # Arguments
@@ -414,7 +396,6 @@ fn write_parquet(_path: &Path, _rows: &[FlatResponse]) -> Result<()> {
/// # Errors
///
/// Returns [`Error::FeatureDisabled`] when the feature is off.
#[cfg(feature = "parquet")]
fn read_parquet(path: &Path) -> Result<ResponseSet> {
let rows = crate::store_parquet::read(path)?;
let mut set = ResponseSet::new();
@@ -422,16 +403,6 @@ fn read_parquet(path: &Path) -> Result<ResponseSet> {
Ok(set)
}
/// Stub for builds without Parquet support.
#[cfg(not(feature = "parquet"))]
fn read_parquet(path: &Path) -> Result<ResponseSet> {
Err(Error::Other(format!(
"{} is a Parquet file, but this build has Parquet support compiled out; \
rebuild with `--features parquet`, or re-ingest with `--format csv`",
path.display()
)))
}
/// Makes an administration id usable as a file name.
///
/// # Arguments
@@ -570,6 +541,7 @@ mod tests {
assessment_id: "exam-4".into(),
date: None,
form: None,
form_position: None,
student_key: student.into(),
sid: None,
name: None,
@@ -579,17 +551,20 @@ mod tests {
item_ref: Some("bank::q-x-001".into()),
item_version: Some(2),
selected: vec!["C".into()],
selected_source: vec![],
eliminated: vec![],
eliminated_source: vec![],
correct: Some(true),
credit: 1.0,
points_possible: 1.5,
score: 1.5,
response_time_seconds: None,
level: None,
learning_objectives: vec!["lo-a".into()],
learning_targets: vec!["lo-a".into()],
topics: vec![],
bonus: false,
dropped: false,
dropped_full_credit: false,
}
}
@@ -618,7 +593,7 @@ mod tests {
assert_eq!(back.rows.len(), 2);
assert_eq!(back.rows[0].item_ref.as_deref(), Some("bank::q-x-001"));
assert_eq!(back.rows[0].selected, vec!["C".to_string()]);
assert_eq!(back.rows[0].learning_objectives, vec!["lo-a".to_string()]);
assert_eq!(back.rows[0].learning_targets, vec!["lo-a".to_string()]);
let all = store.read_all().unwrap();
assert_eq!(all.rows.len(), 2);
+51 -5
View File
@@ -57,10 +57,16 @@ pub fn schema() -> Schema {
Field::new("score", DataType::Float64, false),
Field::new("response_time_seconds", DataType::Utf8, false),
Field::new("level", DataType::UInt32, false),
// The stored column name predates the objective/target rename and is left
// alone: an existing parquet file has to keep loading.
Field::new("learning_objectives", DataType::Utf8, false),
Field::new("topics", DataType::Utf8, false),
Field::new("bonus", DataType::Boolean, false),
Field::new("dropped", DataType::Boolean, false),
Field::new("form_position", DataType::UInt32, false),
Field::new("dropped_full_credit", DataType::Boolean, false),
Field::new("selected_source", DataType::Utf8, false),
Field::new("eliminated_source", DataType::Utf8, false),
])
}
@@ -116,10 +122,14 @@ fn to_batch(rows: &[FlatResponse]) -> Result<RecordBatch> {
f64c(|r| r.score),
s(|r| &r.response_time_seconds),
u32c(|r| r.level as u32),
s(|r| &r.learning_objectives),
s(|r| &r.learning_targets),
s(|r| &r.topics),
boolc(|r| r.bonus),
boolc(|r| r.dropped),
u32c(|r| r.form_position),
boolc(|r| r.dropped_full_credit),
s(|r| &r.selected_source),
s(|r| &r.eliminated_source),
];
RecordBatch::try_new(Arc::new(schema()), columns).map_err(|e| {
@@ -237,6 +247,25 @@ fn from_batch(batch: &RecordBatch, path: &Path) -> Result<Vec<FlatResponse>> {
.ok_or_else(|| column_error(name, "boolean", path))
};
// The three decode columns are read optionally rather than required, so a
// file written before seals existed still loads. A missing column is not a
// foreign file; it is last term's data.
let optional_strings = |name: &str| -> Option<&StringArray> {
batch
.column_by_name(name)
.and_then(|c| c.as_any().downcast_ref::<StringArray>())
};
let optional_uints = |name: &str| -> Option<&UInt32Array> {
batch
.column_by_name(name)
.and_then(|c| c.as_any().downcast_ref::<UInt32Array>())
};
let optional_bools = |name: &str| -> Option<&BooleanArray> {
batch
.column_by_name(name)
.and_then(|c| c.as_any().downcast_ref::<BooleanArray>())
};
let administration_id = strings("administration_id")?;
let course = strings("course")?;
let term = strings("term")?;
@@ -258,11 +287,16 @@ fn from_batch(batch: &RecordBatch, path: &Path) -> Result<Vec<FlatResponse>> {
let score = floats("score")?;
let response_time_seconds = strings("response_time_seconds")?;
let level = uints("level")?;
let learning_objectives = strings("learning_objectives")?;
let learning_targets = strings("learning_objectives")?;
let topics = strings("topics")?;
let bonus = bools("bonus")?;
let dropped = bools("dropped")?;
let form_position = optional_uints("form_position");
let dropped_full_credit = optional_bools("dropped_full_credit");
let selected_source = optional_strings("selected_source");
let eliminated_source = optional_strings("eliminated_source");
let mut out = Vec::with_capacity(batch.num_rows());
for i in 0..batch.num_rows() {
out.push(FlatResponse {
@@ -272,6 +306,7 @@ fn from_batch(batch: &RecordBatch, path: &Path) -> Result<Vec<FlatResponse>> {
assessment_id: assessment_id.value(i).to_string(),
date: date.value(i).to_string(),
form: form.value(i).to_string(),
form_position: form_position.map(|a| a.value(i)).unwrap_or(0),
student_key: student_key.value(i).to_string(),
sid: sid.value(i).to_string(),
email: email.value(i).to_string(),
@@ -280,17 +315,24 @@ fn from_batch(batch: &RecordBatch, path: &Path) -> Result<Vec<FlatResponse>> {
item_ref: item_ref.value(i).to_string(),
item_version: item_version.value(i),
selected: selected.value(i).to_string(),
selected_source: selected_source
.map(|a| a.value(i).to_string())
.unwrap_or_default(),
eliminated: eliminated.value(i).to_string(),
eliminated_source: eliminated_source
.map(|a| a.value(i).to_string())
.unwrap_or_default(),
correct: correct.value(i).to_string(),
credit: credit.value(i),
points_possible: points_possible.value(i),
score: score.value(i),
response_time_seconds: response_time_seconds.value(i).to_string(),
level: level.value(i) as u8,
learning_objectives: learning_objectives.value(i).to_string(),
learning_targets: learning_targets.value(i).to_string(),
topics: topics.value(i).to_string(),
bonus: bonus.value(i),
dropped: dropped.value(i),
dropped_full_credit: dropped_full_credit.map(|a| a.value(i)).unwrap_or(false),
});
}
Ok(out)
@@ -332,10 +374,14 @@ mod tests {
score: 1.5,
response_time_seconds: "42.0".into(),
level: 3,
learning_objectives: "lo-a,lo-b".into(),
learning_targets: "lo-a,lo-b".into(),
topics: "kinetics".into(),
bonus: false,
dropped: false,
dropped_full_credit: false,
form_position: 0,
selected_source: String::new(),
eliminated_source: String::new(),
}
}
@@ -360,7 +406,7 @@ mod tests {
assert_eq!(back.len(), 2);
assert_eq!(back[0].student_key, "s1");
assert_eq!(back[1].item_number, 2);
assert_eq!(back[0].learning_objectives, "lo-a,lo-b");
assert_eq!(back[0].learning_targets, "lo-a,lo-b");
assert_eq!(back[0].credit, 1.0);
assert_eq!(back[0].level, 3);
assert!(!back[0].bonus);
+6
View File
@@ -8,7 +8,10 @@
//! |:--|:--|:--|
//! | [`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 |
//! | [`site`] | a Quarto partial and an encrypted bundle | a course page with password-gated solutions |
//! | [`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 +23,9 @@
//! 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 practice;
pub mod qti;
pub mod report;
pub mod site;
pub mod typst;
+644
View File
@@ -0,0 +1,644 @@
// 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.
//!
//! Two pages come out of here: the lecture's learning objectives with their
//! targets enumerated under each, and its readings.
//!
//! [`Style::Quarto`] reproduces the definition-list shape a Quarto page wants,
//! with `_(T 4, 7)_` numbering resolved from [`numbered_targets`]. Those numbers
//! are positional and so cannot be authored: inserting a target 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 `<br>` before the objective line, which is
/// what a Quarto lecture page uses.
#[default]
Quarto,
/// Plain Markdown bullets, for a report or a README.
Plain,
}
/// How a lecture's targets group under its objectives.
///
/// One function feeds every page, so the printed order and the numbering cannot
/// come apart.
///
/// # Arguments
///
/// * `course` - the loaded course file.
/// * `lecture` - the lecture id.
///
/// # Returns
///
/// The groups as `(objective id, its targets in this lecture)`, and the
/// leftovers: targets whose objective this lecture does not teach, and
/// objectives that have no targets and so stand as their own.
fn target_layout<'a>(
course: &'a CourseFile,
lecture: &str,
) -> (Vec<(&'a str, Vec<&'a str>)>, Vec<&'a str>) {
let targets = course.lecture_targets(lecture);
let mut groups: Vec<(&str, Vec<&str>)> = Vec::new();
for objective in course.lecture_objectives(lecture) {
let mine: Vec<&str> = course
.targets(objective)
.into_iter()
.filter(|t| targets.contains(t))
.collect();
if !mine.is_empty() {
groups.push((objective, mine));
}
}
let grouped: Vec<&str> = groups
.iter()
.flat_map(|(_, ts)| ts.iter().copied())
.collect();
let leftovers: Vec<&str> = targets
.into_iter()
.filter(|t| !grouped.contains(t))
.collect();
(groups, leftovers)
}
/// Whether a lecture's registry entries use the second tier.
///
/// # Arguments
///
/// * `course` - the loaded course file.
/// * `lecture` - the lecture id.
///
/// # Returns
///
/// `true` when any entry the lecture names is a target.
fn is_tiered(course: &CourseFile, lecture: &str) -> bool {
course
.lecture_entries(lecture)
.iter()
.any(|id| course.is_target(id))
}
/// The entries a lecture's pages number, in the order they print.
///
/// Numbering is a property of the *targets* page, because that is the tier a
/// reading serves: a section of a textbook backs a specific performance, not a
/// whole objective. An untiered lecture numbers its objectives instead, since
/// there each objective is its own target.
///
/// # Arguments
///
/// * `course` - the loaded course file.
/// * `lecture` - the lecture id.
///
/// # Returns
///
/// Registry ids in printed order.
pub fn numbered_targets(course: &CourseFile, lecture: &str) -> Vec<String> {
if !is_tiered(course, lecture) {
// Untiered page: level groups, in the order they print, which is the
// numbering this page has always had.
return by_level(course, &course.lecture_entries(lecture))
.into_iter()
.flat_map(|(_, members)| members)
.map(str::to_string)
.collect();
}
let (groups, leftovers) = target_layout(course, lecture);
groups
.into_iter()
.flat_map(|(_, targets)| targets)
.chain(leftovers)
.map(str::to_string)
.collect()
}
/// Groups ids by the level they are assessed up to, in taxonomy order.
///
/// # Arguments
///
/// * `course` - the loaded course file.
/// * `ids` - the ids to group.
///
/// # Returns
///
/// Non-empty groups, with whatever declares no ceiling last under `None`.
fn by_level<'a>(course: &CourseFile, ids: &[&'a str]) -> Vec<(Option<Level>, Vec<&'a str>)> {
let mut groups: Vec<(Option<Level>, Vec<&str>)> =
Level::ALL.iter().map(|l| (Some(*l), Vec::new())).collect();
groups.push((None, Vec::new()));
for id in ids {
let ceiling = course.effective_level_ceiling(id);
if let Some(slot) = groups.iter_mut().find(|(level, _)| *level == ceiling) {
slot.1.push(id);
}
}
groups.retain(|(_, members)| !members.is_empty());
groups
}
/// Renders the learning objectives for one lecture, with each objective's
/// targets enumerated beneath it.
///
/// One page rather than two. The objective and its targets are the same claim
/// at two grains, so printing them in separate sections would list every target
/// twice and leave a reader matching them up by hand.
///
/// The objective is set in bold rather than as a heading, and the targets are
/// numbered: the numbers are what a reading's `_(T 4, 7)_` points at, and they
/// run continuously down the page rather than restarting under each objective,
/// because a reading cites a target without caring which objective it serves.
/// Pandoc's `(@)` example lists continue numbering across the paragraphs
/// between them, which is why the objective lines can sit in the middle of the
/// sequence without breaking it.
///
/// An objective with no targets of its own is printed as a numbered line
/// instead, since it stands as its own target and a reading may cite it.
/// Bolding it and then repeating it as its only target is the duplication this
/// layout exists to avoid.
///
/// On a lecture whose entries are all objectives, the output is what it has
/// always been, grouped by level.
///
/// # Arguments
///
/// * `course` - the loaded course file.
/// * `lecture` - the lecture id.
/// * `style` - which flavour to emit.
///
/// # Returns
///
/// The Markdown, ending in a newline. On an untiered lecture, 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<String> {
course.lecture(lecture, "lecture page")?;
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");
if !is_tiered(course, lecture) {
// Levels in taxonomy order, then whatever declares no ceiling.
for (level, members) in by_level(course, &course.lecture_entries(lecture)) {
if let Some(level) = level {
out.push_str(&format!("### {}\n\n", level.name()));
}
for id in members {
out.push_str(&bullet(&course.text_for(id), style));
}
out.push('\n');
}
return Ok(out);
}
let (groups, leftovers) = target_layout(course, lecture);
for (objective, targets) in groups {
out.push_str(&format!("**{}**\n\n", course.text_for(objective)));
for target in targets {
out.push_str(&bullet(&course.text_for(target), style));
}
out.push('\n');
}
// Targets whose objective this lecture does not teach, and objectives with
// no targets, in the order `numbered_targets` expects them.
if !leftovers.is_empty() {
for id in leftovers {
out.push_str(&bullet(&course.text_for(id), style));
}
out.push('\n');
}
Ok(out)
}
/// One list item, numbered so a reading can point at it.
///
/// # Arguments
///
/// * `text` - the objective or target text.
/// * `style` - which flavour to emit.
///
/// # Returns
///
/// The line, ending in a newline.
fn bullet(text: &str, style: Style) -> String {
match style {
Style::Quarto => format!("(@) {text}\n"),
Style::Plain => format!("1. {text}\n"),
}
}
/// 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<String> {
let lec = course.lecture(lecture, "lecture page")?;
// Positional numbers for this page, so `{lo-id}` in a note and the trailing
// `_(T ...)_` agree with the enumerated targets printed above them.
let order = numbered_targets(course, 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<usize>,
) -> Result<String> {
// 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<String> = [&reading.summary, &reading.focus, &reading.skip]
.into_iter()
.flatten()
.map(|prose| {
course.expand_objective_refs(prose, |id| match number(id) {
Some(n) => format!("T {n}"),
None => course.text_for(id),
})
})
.collect();
match style {
Style::Quarto => {
if !body.is_empty() {
out.push_str(&format!(": {}\n", body.join("\n")));
}
let mut numbers: Vec<usize> =
reading.targets.iter().filter_map(|o| number(o)).collect();
numbers.sort_unstable();
if !numbers.is_empty() {
let list: Vec<String> = numbers.iter().map(|n| n.to_string()).collect();
out.push_str(&format!("<br>\n_(T {})_\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, Target};
/// 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.learning_targets.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()),
targets: 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,
targets: 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 T 1.
<br>
_(T 1, 2)_
### Supplemental
`KKW` [§1.9](https://example.org/kkw/1/B/#9)
: Background.
<br>
_(T 2)_
";
assert_eq!(md, expected);
}
#[test]
fn target_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("_(T 1, 2)_"));
assert!(md.contains("A worked instance of T 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);
}
/// The fixture course with its two entries moved into the target registry
/// under a new objective, which is the shape a migrated course has.
fn tiered_course() -> CourseFile {
let mut c = course();
c.learning_objectives.insert(
"lo-binding".into(),
Objective {
text: "Quantify binding.".into(),
lectures: vec!["L1.1".into()],
order: Some(0),
..objective_defaults()
},
);
// The readings cite `lo-second` and `lo-first`, so the ids are kept and
// only the registry they live in changes. A real migration renames them
// to the `t-` spelling and rewrites the citations with them.
for (id, order) in [("lo-first", 1), ("lo-second", 2)] {
let objective = c.learning_objectives.remove(id).expect("fixture");
c.learning_targets.insert(
id.into(),
Target {
text: objective.text,
objective: "lo-binding".into(),
lectures: objective.lectures,
order: Some(order),
level_ceiling: None,
prerequisites: Vec::new(),
tags: Vec::new(),
assessed: true,
},
);
}
c
}
#[test]
fn the_page_bolds_each_objective_and_enumerates_its_targets() {
let md = objectives_markdown(&tiered_course(), "L1.1", Style::Quarto).expect("renders");
let expected = "\
## Learning objectives
After this lecture, you should be able to do the following.
**Quantify binding.**
(@) objective lo-first
(@) objective lo-second
";
assert_eq!(md, expected);
// Each target appears once on the page, which is the point of merging
// the two sections.
assert_eq!(md.matches("objective lo-first").count(), 1);
}
#[test]
fn reading_numbers_point_at_the_enumerated_targets() {
// The numbers on the page and the ones a reading cites come from the
// same function, so they cannot drift apart.
let readings = readings_markdown(&tiered_course(), "L1.1", Style::Quarto).expect("renders");
assert!(readings.contains("_(T 1, 2)_"), "{readings}");
assert!(readings.contains("A worked instance of T 1."));
}
#[test]
fn an_untiered_lecture_is_grouped_by_level_as_before() {
// Where no targets are declared, each objective is its own target and
// there is nothing to nest, so the page keeps its level headings.
let md = objectives_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
assert!(md.contains("objective lo-first"), "{md}");
assert!(md.contains("objective lo-second"), "{md}");
assert!(
!md.contains("**"),
"nothing to bold on an untiered page: {md}"
);
}
#[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());
}
}
+681
View File
@@ -0,0 +1,681 @@
// 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<Variant> {
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<String> {
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<String> {
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<String> {
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<String> = None;
let layout = select::layout(record, &opts.form);
for (position, placement) in layout.iter().filter(|p| p.was_printed()).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 {
// Both documents name themselves. Two PDFs called "Homework 1" are
// indistinguishable in a downloads folder, which is how a solutions copy
// gets posted in place of the worksheet.
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<String> = 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_targets.is_empty() {
return;
}
let texts: Vec<String> = item
.learning_targets
.iter()
.map(|id| course.text_for(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<String> = 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<String> {
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_targets: [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_targets: [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_targets: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
dropped_as: None,
dropped_before_printing: 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_targets: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
dropped_as: None,
dropped_before_printing: 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\""), "{ws}");
let sol = solutions(
&catalog("front-matter-sol"),
&record(),
&Options::default().form,
)
.expect("renders");
assert!(sol.contains("title: \"Homework 1: Solutions\""), "{sol}");
// Neither document may title itself with the bare assessment name: two
// PDFs called "Homework 1" are indistinguishable once downloaded, and
// the pair that gets confused is the one with the answers in it.
assert!(!ws.contains("title: \"Homework 1\""), "{ws}");
assert!(!sol.contains("title: \"Homework 1\""), "{sol}");
}
}
+824 -47
View File
File diff suppressed because it is too large Load Diff
+77 -31
View File
@@ -36,7 +36,7 @@ use crate::course::CourseFile;
use crate::date::Date;
use crate::irt::Fit;
use crate::students::{Cohort, Mastery, StudentSummary};
use crate::taxonomy::Level;
use crate::taxonomy::{Level, Tier};
/// What to include in a student report.
#[derive(Debug, Clone)]
@@ -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,17 +144,38 @@ pub fn student(
}
}
// ----------------------------------------------------------- objectives
if opts.objectives && !summary.objectives.is_empty() {
// --- objectives
//
// Objectives only. A target row would rest on one question and could only
// ever read "not enough questions to say", so a mixed table buried the few
// real classifications among dozens of non-statements. The targets appear
// where they can be acted on instead: under each lecture in "what to
// revise", and beside each missed question.
if opts.objectives && summary.objectives.iter().any(|o| o.tier == Tier::Objective) {
out.push_str("## What this exam says about each learning objective\n\n");
out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n");
for o in &summary.objectives {
let you = format!("{:.0}%", o.rate * 100.0);
for o in summary
.objectives
.iter()
.filter(|o| o.tier == Tier::Objective)
{
// How much of the objective this exam reached, which is the scope of
// the claim the row makes.
let scope = if o.targets_total > 0 {
format!(
"{} ({} of {} targets tested)",
escape_pipes(&o.text),
o.targets_seen,
o.targets_total
)
} else {
escape_pipes(&o.text)
};
out.push_str(&format!(
"| {} | {} | {} | {:.0}% | {} |\n",
"| {} | {} | {:.0}% | {:.0}% | {} |\n",
o.status.symbol(),
escape_pipes(&o.text),
you,
scope,
o.rate * 100.0,
o.cohort_rate * 100.0,
o.n_items
));
@@ -167,7 +188,7 @@ pub fn student(
let thin: Vec<&str> = summary
.objectives
.iter()
.filter(|o| o.status == Mastery::NotEnoughEvidence)
.filter(|o| o.tier == Tier::Objective && o.status == Mastery::NotEnoughEvidence)
.map(|o| o.text.as_str())
.collect();
if !thin.is_empty() {
@@ -180,7 +201,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,12 +252,12 @@ 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");
for (i, id) in summary.focus.iter().take(4).enumerate() {
let text = course.objective_text(id);
let text = course.text_for(id);
out.push_str(&format!("{}. {}\n", i + 1, text));
}
out.push('\n');
@@ -250,10 +271,7 @@ pub fn student(
.map(|(id, _)| id.as_str())
.collect();
if !class_gaps.is_empty() {
let texts: Vec<String> = class_gaps
.iter()
.map(|id| course.objective_text(id))
.collect();
let texts: Vec<String> = class_gaps.iter().map(|id| course.text_for(id)).collect();
let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
out.push_str(&format!(
"Most of the class also struggled with {}, so expect it to come back in class. \
@@ -268,13 +286,13 @@ pub fn student(
.strengths
.iter()
.take(4)
.map(|id| course.objective_text(id))
.map(|id| course.text_for(id))
.collect();
let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
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(
@@ -288,6 +306,20 @@ pub fn student(
String::new()
};
out.push_str(&format!("**Question {}**{partial}\n\n", m.number));
// Both tiers: the target says what this question asked, the
// objective says which row of the table above it counted toward.
for target in &m.learning_targets {
let objective = course.objective_for(target);
if objective == target.as_str() {
out.push_str(&format!("Asked you to: {}\n\n", course.text_for(target)));
} else {
out.push_str(&format!(
"Asked you to: {} \nCounts toward: {}\n\n",
course.text_for(target),
course.text_for(objective)
));
}
}
if let Some(text) = &m.feedback {
out.push_str(&format!("{text}\n\n"));
} else if let Some(misconception) = &m.misconception {
@@ -356,7 +388,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 +440,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 +509,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 +567,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");
@@ -549,14 +581,14 @@ pub fn cohort(
for (id, rate) in &cohort.class_gaps {
out.push_str(&format!(
"| {} | {:.0}% |\n",
escape_pipes(&course.objective_text(id)),
escape_pipes(&course.text_for(id)),
rate * 100.0
));
}
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");
@@ -576,7 +608,7 @@ pub fn cohort(
out.push('\n');
if let Some(bp) = &record.blueprint {
let drift = crate::select::check_blueprint(record);
let drift = crate::select::check_blueprint(record, course);
if !drift.is_empty() {
out.push_str("Blueprint drift:\n\n");
for d in &drift {
@@ -587,7 +619,7 @@ pub fn cohort(
let _ = bp;
}
// -------------------------------------------------------- archetypes
// --- archetypes
if !cohort.archetypes.is_empty() {
out.push_str("## Patterns across students\n\n");
out.push_str(
@@ -956,6 +988,10 @@ pub fn write_all_students(
/// Per-objective class rates as a compact table, for pasting into a syllabus
/// review or a curriculum committee document.
///
/// Objectives only. A committee document listing three hundred learning targets
/// is not read, and the target rates are the wrong number to put in front of one
/// anyway: each rests on one or two questions.
///
/// # Arguments
///
/// * `cohort` - the class.
@@ -965,7 +1001,7 @@ pub fn write_all_students(
///
/// A Markdown table.
pub fn objective_summary(cohort: &Cohort, course: &CourseFile) -> String {
let mut out = String::from("| Objective | Class rate |\n|:--|--:|\n");
let mut out = String::from("| Objective | Class rate | Items |\n|:--|--:|--:|\n");
let mut rows: Vec<(&String, &f64)> = cohort.objective_rates.iter().collect();
rows.sort_by(|a, b| {
a.1.partial_cmp(b.1)
@@ -973,10 +1009,20 @@ pub fn objective_summary(cohort: &Cohort, course: &CourseFile) -> String {
.then_with(|| a.0.cmp(b.0))
});
for (id, rate) in rows {
// Items behind the rate, because a rate without a denominator is what
// makes a committee table misleading.
let n = cohort
.students
.iter()
.flat_map(|s| s.objectives.iter())
.find(|o| &o.id == id)
.map(|o| o.n_items)
.unwrap_or(0);
out.push_str(&format!(
"| {} | {:.0}% |\n",
escape_pipes(&course.objective_text(id)),
rate * 100.0
"| {} | {:.0}% | {} |\n",
escape_pipes(&course.text_for(id)),
rate * 100.0,
n
));
}
out
+1032
View File
File diff suppressed because it is too large Load Diff
+20 -3
View File
@@ -50,6 +50,7 @@
//! the template by someone who has not read this comment.
pub mod config;
pub mod diagnostic;
pub mod payload;
pub mod template;
pub mod value;
@@ -68,6 +69,7 @@ use crate::Layout;
use crate::assessment::{AssessmentFile, Form};
use crate::catalog::Catalog;
use crate::error::{Error, Result};
use crate::markup;
/// What to render.
#[derive(Debug, Clone)]
@@ -194,6 +196,17 @@ pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Res
}
let mut warnings = payload::check(&payload, &opts.config);
for (slot, body) in &bodies {
if markup::needs_chem_import(&template.source, body) {
warnings.push(format!(
"the `{}` slot carries a chemical formula, but the template {} does not import \
whalogen, so Typst will stop at `unknown variable: ce`; add `{}`",
slot.as_str(),
template.origin,
markup::CHEM_IMPORT
));
}
}
if template.is_inert() {
warnings.push(format!(
"the template {} declares no coursebank markers, so no questions were injected; add \
@@ -423,9 +436,11 @@ mod tests {
bonus: false,
key: vec!["A".into()],
level: None,
learning_objectives: Vec::new(),
learning_targets: Vec::new(),
credit_overrides: Default::default(),
dropped: true,
dropped_as: None,
dropped_before_printing: false,
},
Placement {
number: 2,
@@ -436,15 +451,17 @@ mod tests {
bonus: false,
key: vec!["B".into()],
level: None,
learning_objectives: Vec::new(),
learning_targets: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
dropped_as: None,
dropped_before_printing: false,
},
],
};
let printable: Vec<u32> = select::layout(&record, &Options::default().form)
.into_iter()
.filter(|p| !p.dropped)
.filter(|p| p.was_printed())
.map(|p| p.number)
.collect();
assert_eq!(printable, vec![2]);
+123 -1
View File
@@ -43,11 +43,25 @@ pub enum Variant {
Key,
/// A bubble sheet matching the form.
AnswerSheet,
/// One student's diagnostic, which carries no questions.
StudentReport,
/// The instructor's class diagnostic.
CohortReport,
}
impl Variant {
/// Every variant, in the order `export` writes them.
pub const ALL: [Variant; 3] = [Variant::Exam, Variant::Key, Variant::AnswerSheet];
pub const ALL: [Variant; 5] = [
Variant::Exam,
Variant::Key,
Variant::AnswerSheet,
Variant::StudentReport,
Variant::CohortReport,
];
/// The variants `export typst` produces. A report is not built from an
/// assessment record alone, so `export typst` must not default to it.
pub const EXAM: [Variant; 3] = [Variant::Exam, Variant::Key, Variant::AnswerSheet];
/// The token used on the command line, in config keys, and in file names.
pub fn as_str(self) -> &'static str {
@@ -55,6 +69,8 @@ impl Variant {
Variant::Exam => "exam",
Variant::Key => "key",
Variant::AnswerSheet => "answer-sheet",
Variant::StudentReport => "student-report",
Variant::CohortReport => "cohort-report",
}
}
@@ -103,6 +119,8 @@ impl Variant {
Variant::Exam => "",
Variant::Key => "-key",
Variant::AnswerSheet => "-answer-sheet",
Variant::StudentReport => "-student",
Variant::CohortReport => "-cohort",
}
}
}
@@ -310,6 +328,58 @@ impl Default for Fields {
}
}
/// Which sections of a student diagnostic are emitted.
///
/// Everything defaults on. Each flag corresponds to one block that
/// [`crate::typst::diagnostic::student_value`] would otherwise write
/// unconditionally: turning one off drops it from `cb-data` as an empty array
/// rather than omitting the key, so a template that checks `len() > 0` (the
/// pattern the bundled templates use) simply renders nothing for that section
/// without needing to guard against a missing key.
///
/// This governs whole sections. Which fields survive *within* a question —
/// feedback, hints, misconceptions, worked solutions — is decided when the
/// diagnostic itself is built, not here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StudentSections {
/// The per-level ("kinds of thinking") comparison to the class.
#[serde(default = "yes")]
pub levels: bool,
/// The per-objective mastery table.
#[serde(default = "yes")]
pub objectives: bool,
/// Objectives called out as strengths.
#[serde(default = "yes")]
pub strengths: bool,
/// Objectives called out as focus areas.
#[serde(default = "yes")]
pub focus: bool,
/// Which questions were dropped from scoring.
#[serde(default = "yes")]
pub dropped_questions: bool,
/// Lectures to revisit for missed objectives.
#[serde(default = "yes")]
pub review_lectures: bool,
/// Suggested study groups and their readings.
#[serde(default = "yes")]
pub study: bool,
}
impl Default for StudentSections {
fn default() -> StudentSections {
StudentSections {
levels: true,
objectives: true,
strengths: true,
focus: true,
dropped_questions: true,
review_lectures: true,
study: true,
}
}
}
/// The resolved configuration for rendering one variant.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -360,6 +430,11 @@ pub struct RenderConfig {
#[serde(default = "yes")]
pub number_from_record: bool,
/// Which sections of a student diagnostic are emitted. Ignored by every
/// variant except [`Variant::StudentReport`].
#[serde(default)]
pub student_sections: StudentSections,
/// Anything else you want the template to see, carried through untouched.
///
/// This is the escape hatch that keeps the crate out of your layout
@@ -392,6 +467,7 @@ impl RenderConfig {
stimulus: StimulusMode::Inline,
fields: Fields::default(),
number_from_record: true,
student_sections: StudentSections::default(),
extra: BTreeMap::new(),
};
match variant {
@@ -422,6 +498,30 @@ impl RenderConfig {
calibration: false,
};
}
Variant::StudentReport => {
// Belt and braces. The student payload is built by
// `typst::diagnostic`, which has no question text to reveal in the
// first place; this says so in the one place someone would look.
config.reveal = Reveal::Nothing;
config.stimulus = StimulusMode::Omit;
config.fields = Fields {
uid: false,
title: false,
points: true,
level: true,
objectives: true,
topics: false,
assets: false,
source_letters: false,
design: false,
calibration: false,
};
}
Variant::CohortReport => {
config.reveal = Reveal::Everything;
config.stimulus = StimulusMode::Omit;
config.fields.calibration = true;
}
}
config
}
@@ -518,6 +618,9 @@ pub struct Overrides {
/// See [`RenderConfig::fields`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fields: Option<Fields>,
/// See [`RenderConfig::student_sections`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub student_sections: Option<StudentSections>,
/// See [`RenderConfig::number_from_record`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub number_from_record: Option<bool>,
@@ -561,6 +664,9 @@ impl Overrides {
if let Some(v) = &self.fields {
config.fields = v.clone();
}
if let Some(v) = &self.student_sections {
config.student_sections = v.clone();
}
if let Some(v) = self.number_from_record {
config.number_from_record = v;
}
@@ -607,6 +713,8 @@ defaults:
extra:
accent: '#017ab9'
font: 'Libertinus Serif'
show-bubbles: true
bubble-radius: '0.42em'
variants:
exam:
@@ -624,6 +732,20 @@ variants:
answer-sheet:
reveal: nothing
# Every student-report section defaults on. Uncomment what you don't want;
# the CLI's `--no-levels`, `--no-objectives`, `--no-strengths`, `--no-focus`,
# `--no-dropped-questions`, `--no-review-lectures`, and `--no-study` flags
# set these same fields for a single run without editing this file.
# student-report:
# student_sections:
# levels: false
# objectives: false
# strengths: false
# focus: false
# dropped_questions: false
# review_lectures: false
# study: false
"#;
/// Serde default: `true`.
File diff suppressed because it is too large Load Diff
+36 -25
View File
@@ -50,7 +50,7 @@ pub struct Payload {
pub form: FormInfo,
/// Counts and sums, so a template does not have to derive them.
pub totals: Totals,
/// Learning objectives referenced by the printed items, by id.
/// Learning objectives and targets referenced by the printed items, by id.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub objectives: BTreeMap<String, Objective>,
/// Shared stimuli, by id. Populated when the render config says stimuli are
@@ -268,9 +268,9 @@ pub struct Question {
/// letter. Omitted unless the config reveals the key.
#[serde(skip_serializing_if = "Option::is_none")]
pub credit_overrides: Option<BTreeMap<String, f64>>,
/// Learning objective ids.
/// Learning target ids.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec<String>,
pub learning_targets: Vec<String>,
/// Topic tags.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub topics: Vec<String>,
@@ -386,10 +386,10 @@ pub fn build(
.or_insert(0) += 1;
}
let objectives = if placement.learning_objectives.is_empty() {
item.learning_objectives.clone()
let objectives = if placement.learning_targets.is_empty() {
item.learning_targets.clone()
} else {
placement.learning_objectives.clone()
placement.learning_targets.clone()
};
if config.fields.objectives {
objective_ids.extend(objectives.iter().cloned());
@@ -467,7 +467,7 @@ pub fn build(
options,
key,
credit_overrides,
learning_objectives: if config.fields.objectives {
learning_targets: if config.fields.objectives {
objectives
} else {
Vec::new()
@@ -501,14 +501,10 @@ pub fn build(
let objectives = objective_ids
.into_iter()
.filter_map(|id| {
course.learning_objectives.get(&id).map(|o| {
(
id,
Objective {
text: markup::to_typst(&o.text),
unit: o.unit.clone(),
},
)
(course.is_objective(&id) || course.is_target(&id)).then(|| {
let text = markup::to_typst(&course.text_for(&id));
let unit = course.objective_unit(&id).map(str::to_string);
(id, Objective { text, unit })
})
})
.collect();
@@ -662,11 +658,7 @@ fn calibration(item: &Item) -> Option<BTreeMap<String, f64>> {
/// 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.
@@ -964,12 +956,12 @@ fn question_value(question: &Question, config: &RenderConfig) -> Value {
}),
);
if !question.learning_objectives.is_empty() {
if !question.learning_targets.is_empty() {
root.insert(
"learning-objectives",
"learning-targets",
Value::Array(
question
.learning_objectives
.learning_targets
.iter()
.map(|s| Value::str(s.as_str()))
.collect(),
@@ -1074,8 +1066,27 @@ fn numeric_map(map: &BTreeMap<String, f64>) -> Value {
)
}
/// Emits authored markup as either a content block or a quoted string.
fn markup_value(source: &str, content: bool) -> Value {
/// Emits one markup-bearing field for a diagnostic report.
///
/// The conversion itself lives in [`markup::to_typst`], which is the single
/// entry point for turning authoring markup into Typst: it rewrites inline
/// LaTeX math, routes mhchem through whalogen, and escapes the characters Typst
/// treats specially in content mode. This path used to carry its own copy of
/// the math rewriting, which drifted — the reports escaped nothing outside math
/// and silently disagreed with the exam papers about an unmatched `$`.
///
/// # Arguments
///
/// * `source` - the authoring source.
/// * `content` - whether to emit a content block rather than a quoted string.
/// A string is evaluated by the template with `eval(.., mode: "markup")`, so
/// both modes need the same escaping.
///
/// # Returns
///
/// The value to place in the payload.
pub(crate) fn markup_value(source: &str, content: bool) -> Value {
let source = markup::to_typst(source);
if content {
Value::content(source)
} else {
+8
View File
@@ -76,6 +76,12 @@ const EMBEDDED_KEY: &str = include_str!("templates/key.typ");
/// The bundled answer sheet template.
const EMBEDDED_ANSWER_SHEET: &str = include_str!("templates/answer-sheet.typ");
/// The bundled student diagnostic template.
const EMBEDDED_STUDENT_REPORT: &str = include_str!("templates/student-report.typ");
/// The bundled class diagnostic template.
const EMBEDDED_COHORT_REPORT: &str = include_str!("templates/cohort-report.typ");
/// An injection point a template can declare.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Slot {
@@ -174,6 +180,8 @@ pub fn embedded(variant: Variant) -> &'static str {
Variant::Exam => EMBEDDED_EXAM,
Variant::Key => EMBEDDED_KEY,
Variant::AnswerSheet => EMBEDDED_ANSWER_SHEET,
Variant::StudentReport => EMBEDDED_STUDENT_REPORT,
Variant::CohortReport => EMBEDDED_COHORT_REPORT,
}
}
@@ -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"),
File diff suppressed because it is too large Load Diff
+146 -45
View File
@@ -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,15 @@
// 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.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
@@ -72,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")
@@ -120,6 +125,36 @@
}
}
// 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)
// 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
// ─────────────────────────────────────────────────────────────────────────────
@@ -137,30 +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(inset: (left: 1.2em))[
#for opt in q.at("options", default: ()) {
grid(
columns: (1.4em, 1fr),
gutter: 0.2em,
[#(opt.letter + ".")], [#markup(opt.text)],
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),
)
v(0.15em)
]
#block(below: 1.5em)[#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(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) }
@@ -170,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,
+21 -1
View File
@@ -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"),
@@ -39,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) }
@@ -0,0 +1,994 @@
// coursebank — individual diagnostic template
//
// This file is a template, not generated output. `coursebank report students`
// replaces only the marked regions below and leaves every other line exactly as
// you wrote it, so this is where layout decisions belong.
//
// coursebank template dump --variant student-report
// typst watch templates/student-report.typ
//
// Two markers are in play:
//
// // coursebank:begin meta course, assessment, how many sat it, policy
// // coursebank:end meta
// // coursebank:begin data one student's diagnostic
// // coursebank:end data
//
// Both regions ship with sample values, so `typst watch` works before any report
// has been generated.
//
// What is not in the payload: the questions. There is no stem field and no option
// text field, on any question, in any configuration. A student report is handed
// back before the makeup exam is given, and a report that reproduces the paper
// cannot be. If you find yourself wanting to print the question, print its number
// and let the student read it off their own copy.
//
// Two fields appear only when you ask for them at the command line, because both
// trade a student's understanding against reusing the question: `misconception`
// (`--misconceptions`) and `worked` (`--solutions`). The blocks that print them
// are below and cost nothing when the fields are absent.
//
// The prose in this file is addressed to a nineteen-year-old reading their own
// result, alone, possibly disappointed. It explains every number before showing
// it. If you change one thing here, keep that.
#import "@preview/mitex:0.2.7": mi
#import "@preview/whalogen:0.3.0": ce
// ─────────────────────────────────────────────────────────────────────────────
// Data
// ─────────────────────────────────────────────────────────────────────────────
// coursebank:begin meta
#let cb-meta = (
course: (code: "COURSE 101", title: "Sample Course", term: "2026f"),
assessment: (id: "sample", title: "Sample assessment", date: "2026-01-01"),
generator: (tool: "coursebank", version: "0.0.0", on: "2026-01-02"),
policy: (mastery-threshold: 0.75, min-items-for-mastery: 2),
students-tested: 24,
class-size: 24,
extra: (:),
)
// coursebank:end meta
// coursebank:begin data
#let cb-data = (
student-key: "s-000000000000",
name: "Sample Student",
email: "sample@example.edu",
form: "A",
score: (points: 27.0, possible: 36.0, percent: 75.0, bonus: 1.0, correct: 27, items: 36),
standing: (class-mean: 71.2, class-sd: 11.4, band: "upper half"),
levels: (
(
level: 1,
name: "Remember",
blurb: "recalling terms, facts, and definitions",
items: 6,
rate: 1.0,
class-rate: 0.91,
comparison: "above the class",
),
(
level: 3,
name: "Apply",
blurb: "using a procedure in a new situation",
items: 9,
rate: 0.56,
class-rate: 0.64,
comparison: "below the class",
),
),
objectives: (
(
id: "lo-sample-met",
text: [A sample objective this student met.],
items: 3,
credit: 3.0,
rate: 1.0,
lower: 0.44,
upper: 1.0,
class-rate: 0.81,
status: "meeting",
symbol: "✓",
confident: false,
thin-evidence: false,
levels: (1, 2),
),
(
id: "lo-sample-gap",
text: [A sample objective to work on.],
items: 3,
credit: 1.0,
rate: 0.33,
lower: 0.06,
upper: 0.79,
class-rate: 0.58,
status: "not yet",
symbol: "✗",
confident: false,
thin-evidence: false,
levels: (3,),
),
),
strengths: ((id: "lo-sample-met", text: [A sample objective this student met.], rate: 1.0, items: 3),),
focus: ((id: "lo-sample-gap", text: [A sample objective to work on.], rate: 0.33, items: 3),),
questions: (
(
number: 1,
level: 1,
targets: ("t-sample-met",),
measured: (),
correct: true,
credit: 1.0,
bonus: false,
dropped: false,
blank: false,
class-rate: 0.91,
taught-in: (),
review: (),
),
(
number: 2,
level: 3,
targets: ("t-sample-gap",),
measured: ((
objective: [A sample objective to work on.],
target: [A sample learning target under it.],
),),
correct: false,
credit: 0.0,
bonus: false,
blank: false,
class-rate: 0.58,
feedback: [This is the note written for the option that was chosen.],
hint: [This is the question you would ask someone reconsidering that option.],
taught-in: ("Enthalpy (L1.1), slides 12, 13",),
review: ((citation: "KKW §6.2", title: "Molecules and Medicine", url: "https://example.edu/6/2"),),
),
),
dropped-questions: ((number: 35, full-credit: true),),
review-lectures: (
(
lecture: "L1.1",
title: "Enthalpy",
targets-missed: 2,
questions-missed: 3,
questions: (2, 14, 15),
slides: (12, 13),
targets: ([A sample learning target to work on.], [A second one from the same lecture.]),
),
),
study: (
(
objective: "lo-sample-gap",
text: [A sample objective to work on.],
rate: 0.33,
readings: (
(
citation: "KKW §6.2",
lecture: "L1.1",
lecture-title: "Enthalpy",
focus: [What to take from this section.],
supplemental: false,
),
),
),
),
)
// coursebank:end data
// ─────────────────────────────────────────────────────────────────────────────
// Settings
// ─────────────────────────────────────────────────────────────────────────────
#let extra = cb-meta.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: "10pt"))
#let paper = extra.at("paper", default: "us-letter")
// A five-step type scale. Sizes are picked from this list rather than invented at
// the call site, which is what stops a document from drifting into a dozen
// slightly different smalls.
#let size-tag = 0.6em // the level tag inside a question box
#let size-micro = 0.72em // column heads, badges, legends
#let size-meta = 0.9em // provenance: lecture ids, citations, dates
#let size-small = 0.88em // table cells, objective bullets, callouts
#let size-lead = 0.94em // the explanation under each heading
#let size-name = 1.1em // the student's name
#let size-h2 = 1.02em
#let size-h1 = 1.15em
#let size-display = 1.4em // the three numbers at the top
#let size-title = 1.5em
// One vertical step. Every gap inside a list entry is this or a stated multiple
// of it, and the entries themselves are separated by `entry-gap`. Uneven rhythm
// in a document like this comes from mixing `v()`, linebreaks, and Typst's
// default paragraph spacing in the same block; a stack plus one step avoids all
// three.
#let step = 1.2em
#let entry-gap = 1.0em
// Prose is held to a readable measure instead of spanning the full text block.
// At 10pt across 17.8cm a line runs to about a hundred characters, which is
// roughly a third too long to track comfortably. Tables and the question grid
// still use the whole width, which is what they are for.
#let prose-pad = 1.5cm
// The same right edge for prose that already sits in a gutter, so a note body
// lines up with the explanation above it. One centimetre is the 2.8em of number
// column plus gutter at the default body size.
#let prose-pad-inset = prose-pad - 1cm
// Turn sections off from templates/typst.yaml rather than by deleting code, so
// a course that does not want the question map keeps the rest of this file.
#let show-question-map = extra.at("question-map", default: true)
#let show-feedback = extra.at("feedback", default: true)
#let show-study = extra.at("study-plan", default: true)
#let show-comparison = extra.at("comparison", default: true)
#let show-lecture-plan = extra.at("lecture-plan", default: true)
#let show-item-readings = extra.at("item-readings", default: true)
#let show-intro = extra.at("intro", default: true)
#let threshold = cb-meta.policy.at("mastery-threshold", default: 0.75)
#let n-tested = cb-meta.at("students-tested", default: cb-meta.at("class-size", default: 0))
#let ok-color = rgb("#2a9d8f")
#let mid-color = rgb("#D19F1F")
#let bad-color = rgb("#E24E29")
#let thin-color = luma(150)
#set page(
paper: paper,
margin: (x: 1.9cm, y: 2.1cm),
header: text(size: size-meta, fill: luma(110))[
#cb-meta.course.code · #cb-meta.assessment.title · individual diagnostic
],
footer: context text(size: size-meta, fill: luma(110))[
#h(1fr)
Page #counter(page).display("1 of 1", both: true)
],
)
#set text(font: body-font, size: body-size, lang: "en")
#set par(justify: false, leading: 0.62em)
// Figures in the tables are columns of numbers, so they get tabular widths and
// line up under one another.
#show table: set text(number-width: "tabular")
#show heading.where(level: 1): it => block(above: 1.5em, below: entry-gap)[
#text(size: size-h1, weight: "bold", fill: accent)[#it.body]
#v(-0.45em)
#line(length: 100%, stroke: 0.6pt + accent.lighten(55%))
]
#show heading.where(level: 2): it => block(above: 1em, below: 0.45em)[
#text(size: size-h2, weight: "bold")[#it.body]
]
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
#let markup(v) = if type(v) == str { eval(v, mode: "markup") } else { v }
// A rate is a fraction in 0..1; a percent is already out of 100. Keeping the two
// straight is the only arithmetic this template does.
#let pct(rate) = str(calc.round(rate * 100)) + "%"
#let pct1(value) = str(calc.round(value, digits: 0)) + "%"
#let plural(n, one, many) = if n == 1 { one } else { many }
#let status-color(status) = {
if status == "meeting" { ok-color } else if status == "developing" { mid-color } else if status == "not yet" {
bad-color
} else { thin-color }
}
#let rate-color(rate) = {
if rate >= threshold { ok-color } else if rate >= threshold * 0.6 { mid-color } else { bad-color }
}
#let badge(label, color) = box(
fill: color.lighten(82%),
radius: 3pt,
inset: (x: 5pt, y: 2.5pt),
)[#text(size: size-micro, weight: "bold", fill: color.darken(12%))[#label]]
// A column head. One definition rather than the same three arguments repeated at
// every header cell, so the heads cannot drift apart.
#let th(body) = text(size: size-micro, fill: luma(95), tracking: 0.04em)[#body]
// A numbered disc for a ranked list. Fixed width, so the text of every entry
// starts at the same place no matter whether the rank is 1 or 11.
#let rank(n, color) = box(
width: 1.35em,
height: 1.35em,
radius: 50%,
fill: color,
)[#align(center + horizon)[#text(size: size-micro, weight: "bold", fill: white)[#str(n)]]]
// An aside with a rule down its left edge: the action to take, set apart from the
// explanation above it without another box or another tint.
#let callout(name, body) = block(
inset: (left: 0.65em),
stroke: (left: 1.5pt + accent.lighten(55%)),
)[
#text(size: size-small)[#text(weight: "bold", fill: luma(75))[#name:] #body]
]
// A provenance line: where something was taught, or what to read.
#let meta-pair(name, body) = text(size: size-meta, fill: luma(115))[
#text(weight: "bold")[#name:] #body
]
// Two boxes side by side rather than an overlay: no `place`, no coordinate
// arithmetic, and it degrades to something sensible at any width.
#let bar(rate, color: accent, width: 3.6cm) = {
let r = calc.max(0.0, calc.min(1.0, rate))
box(baseline: 0.15em)[
#stack(
dir: ltr,
box(width: width * r, height: 0.62em, fill: color, radius: (left: 2pt)),
box(width: width * (1.0 - r), height: 0.62em, fill: luma(232), radius: (right: 2pt)),
)
]
}
// The three cards at the top. The label sits above the number and the gloss
// below it, so the eye lands on the figure and can then read outwards.
#let stat-card(label, value, note: none) = {
let parts = (
text(size: size-micro, fill: luma(95), tracking: 0.06em)[#upper(label)],
text(size: size-display, weight: "bold", fill: accent)[#value],
)
if note != none {
parts.push(text(size: size-meta, fill: luma(95))[#note])
}
block(
height: 9em,
width: 100%,
fill: luma(247),
radius: 4pt,
inset: (x: 10pt, y: 9pt),
)[#stack(dir: ttb, spacing: step * 1.0, ..parts)]
}
// The explanation under a heading. Every section has one, because a number a
// student cannot interpret is worse than no number at all.
#let explain(body) = block(below: entry-gap)[
#pad(right: prose-pad)[#text(size: size-lead, fill: luma(95))[#body]]
]
// ─────────────────────────────────────────────────────────────────────────────
// Heading
// ─────────────────────────────────────────────────────────────────────────────
#block(below: 0.35em)[
#text(size: size-title, weight: "bold")[#cb-meta.assessment.title]
#h(0.6em)
#text(fill: luma(110))[
#cb-meta.course.code · #cb-meta.course.title
]
]
#let student-name = cb-data.at("name", default: none)
#let student-email = cb-data.at("email", default: none)
#let student-sid = cb-data.at("sid", default: none)
#block(below: entry-gap)[
#stack(
dir: ttb,
spacing: step * 0.5,
text(size: size-name, weight: "bold")[
#if student-name != none { student-name } else { cb-data.student-key }
],
{
// Identity on its own line, and only what the export actually carried. A
// label with nothing after it reads like a mistake.
let parts = ()
if student-email != none { parts.push(student-email) }
if student-sid != none { parts.push(student-sid) }
let form = cb-data.at("form", default: none)
if form != none { parts.push("Form " + form) }
let date = cb-meta.assessment.at("date", default: none)
if date != none { parts.push(date) }
if parts.len() > 0 {
text(size: size-meta, fill: luma(110))[#parts.join(" · ")]
}
},
)
]
#if show-intro [
#block(
width: 100%,
fill: accent.lighten(95%),
radius: 4pt,
inset: (x: 11pt, y: 10pt),
below: 1.2em,
)[
#pad(right: prose-pad - 1.1cm)[
#text(size: size-lead)[
This map explains what the exam covered and what your results mean, so you can use the information. Your score appears first because that's what you probably want to see. After that, you'll find more helpful details: which types of questions you did well on, which topics you might want to review, and what to read for each area. The last two sections are the most practical, so if you only read part of this, focus on those.
This report does not judge your abilities, and one question alone does not say much. If a score is based on just one or two questions, the report will point that out so you don't read too much into it.
]
]
]
]
// ─────────────────────────────────────────────────────────────────────────────
// Score
// ─────────────────────────────────────────────────────────────────────────────
#let score = cb-data.score
#let standing = cb-data.at("standing", default: none)
#grid(
columns: (1fr, 1fr, 1fr),
gutter: 10pt,
stat-card(
"your score",
pct1(score.percent),
note: str(score.points)
+ " of "
+ str(score.possible)
+ " points"
+ (if score.at("bonus", default: 0.0) > 0.0 { " (+" + str(score.bonus) + " bonus)" } else { "" }),
),
stat-card(
"questions right",
str(score.correct) + " / " + str(score.items),
note: "out of " + str(score.items) + " " + plural(score.items, "question", "questions"),
),
if standing != none and show-comparison {
stat-card(
"class average",
pct1(standing.class-mean),
note: "across the " + str(n-tested) + " students who took this exam · you are in the " + standing.band,
)
} else {
stat-card(
"students tested",
str(n-tested),
note: "took this exam",
)
},
)
#let dropped-questions = cb-data.at("dropped-questions", default: ())
#if dropped-questions.len() > 0 [
#block(above: entry-gap)[
#pad(right: prose-pad)[
#text(size: size-lead)[
#{
// Two kinds of drop, and they need different sentences. A credited
// question is still in the denominator, so telling a student it was
// removed would not match the arithmetic they can do themselves.
let credited = dropped-questions.filter(d => d.at("full-credit", default: false))
let removed = dropped-questions.filter(d => not d.at("full-credit", default: false))
let numbers = list => list.map(d => str(d.number)).join(", ")
if credited.len() > 0 [
#plural(credited.len(), "Question", "Questions") #numbers(credited)
#plural(credited.len(), "was", "were") thrown out after the exam.
Everyone received full credit for
#plural(credited.len(), "it", "them"), so
#plural(credited.len(), "it is", "they are") still counted in the
score above and whatever you chose made no difference.
]
if removed.len() > 0 [
#plural(removed.len(), "Question", "Questions") #numbers(removed)
#plural(removed.len(), "was", "were") thrown out and removed from
scoring, so your percentage is out of the remaining questions.
Nothing you wrote on #plural(removed.len(), "it", "them") counted
either way.
]
}
]
]
]
]
// ─────────────────────────────────────────────────────────────────────────────
// Levels
// ─────────────────────────────────────────────────────────────────────────────
#if cb-data.levels.len() > 0 [
= How you did, by kind of thinking
#explain[
Each question on this exam was designed to test a specific type of thinking, from recalling definitions to analyzing situations. The table below breaks down your results by these types. The number in brackets shows how many questions of each type you answered. 'You' shows the percentage you got right, and 'class' shows the average for everyone who took the exam.
Focus on the overall pattern, not just the specific numbers. If you did well on recall but struggled with applied questions, try practicing more problems. This is different from just reviewing the material. If your results are similar across all levels, it may be helpful to review the material itself.
]
// The figure comes before its bar in both tables on this page, so the numbers
// read as a column and the bars all start from the same left edge.
#table(
columns: (auto, 1fr, auto, 2.7cm, auto),
stroke: none,
align: (left + horizon, left + horizon, right + horizon, left + horizon, right + horizon),
inset: (x: 5pt, y: 6pt),
fill: (_, row) => if calc.odd(row) { luma(250) } else { white },
table.header(th[LEVEL], th[WHAT IT ASKS FOR], th[YOU], th[], th[CLASS]),
..cb-data
.levels
.map(level => (
[*#level.name* #text(size: size-meta, fill: luma(120))[(#level.items)]],
text(size: size-small, fill: luma(80))[#level.blurb],
text(weight: "medium")[#pct(level.rate)],
bar(level.rate, color: rate-color(level.rate), width: 2.4cm),
{
let class-rate = level.at("class-rate", default: none)
if class-rate != none and show-comparison {
text(size: size-small, fill: luma(100))[#pct(class-rate)]
} else { [] }
},
))
.flatten(),
)
]
// ─────────────────────────────────────────────────────────────────────────────
// Objectives
// ─────────────────────────────────────────────────────────────────────────────
#if cb-data.objectives.len() > 0 [
= What the exam measured, objective by objective
#explain[
Each line is one learning objective, just as it appears in the syllabus. *Q* counts every question on this exam that measured any part of it, so a line usually rests on several questions rather than one. *You* shows the share you got right, and *class* shows the same for everyone else.
The symbol in the first column is the summary. A #text(fill: thin-color, weight: "bold")[?] means this exam did not ask enough about that objective to say anything either way, which is a fact about the exam and not about you. Nothing on this page is broken down question by question; for that, see which lectures to go back to, and the notes on the ones you missed.
]
// The objective text is the only thing in this table that wants width, so it
// takes the free column and everything else is sized to its content. The
// thin-evidence badge that used to sit inline is gone: it repeated on every
// row of a three-page table, wrapped the text, and said no more than the `?`
// in the first column already says.
#table(
columns: (auto, 1fr, auto, auto, 2.4cm, auto),
stroke: none,
align: (center + horizon, left + horizon, right + horizon, right + horizon, left + horizon, right + horizon),
inset: (x: 5pt, y: 6pt),
fill: (_, row) => if calc.odd(row) { luma(250) } else { white },
table.header(th[], th[OBJECTIVE], th[Q], th[YOU], th[], th[CLASS]),
..cb-data
.objectives
.map(objective => (
text(fill: status-color(objective.status), weight: "bold")[#objective.symbol],
text(size: size-small)[#markup(objective.text)],
text(size: size-small, fill: luma(110))[#objective.items],
text(size: size-small, weight: "medium")[#pct(objective.rate)],
bar(objective.rate, color: status-color(objective.status), width: 2.1cm),
{
let class-rate = objective.at("class-rate", default: none)
if class-rate != none and show-comparison {
text(size: size-small, fill: luma(100))[#pct(class-rate)]
} else { [] }
},
))
.flatten(),
)
#v(0.5em)
#text(size: size-micro, fill: luma(110))[
#text(fill: ok-color, weight: "bold")[✓] you have this ·
#text(fill: mid-color, weight: "bold")[~] getting there ·
#text(fill: bad-color, weight: "bold")[✗] not yet ·
#text(fill: thin-color, weight: "bold")[?] too few questions to say, which is
every line measured by one question.
A line counts as solid at #pct(threshold) or better.
]
]
// ─────────────────────────────────────────────────────────────────────────────
// Strengths and focus
// ─────────────────────────────────────────────────────────────────────────────
// The two panels are the same object twice, so they are one function. `rows` is
// a list of (text, trailing) pairs; the trailing part is the rate, which only
// the focus panel carries.
#let panel(title, color, note, rows) = block(
width: 100%,
fill: color.lighten(93%),
radius: 4pt,
inset: 10pt,
)[
#stack(
dir: ttb,
spacing: step,
text(weight: "bold", fill: color.darken(15%))[#title],
text(size: size-small, fill: luma(95))[#note],
{
set text(size: size-small)
list(
indent: 0pt,
body-indent: 0.45em,
spacing: step * 0.7,
marker: text(fill: color.darken(5%))[·],
..rows,
)
},
)
]
// ─────────────────────────────────────────────────────────────────────────────
#let strengths = cb-data.at("strengths", default: ())
#let focus = cb-data.at("focus", default: ())
#if strengths.len() > 0 or focus.len() > 0 [
= Where your time will go furthest
#explain[
These two lists are based on the table above. I left out the single-question lines, so what is left has enough evidence to support action.
]
#grid(
columns: (1fr, 1fr),
gutter: 12pt,
if focus.len() > 0 {
panel(
"Start here",
bad-color,
[Start by reviewing the material that is likely to change the most and is the hardest for you.],
focus.map(objective => [
#markup(objective.text)
#text(size: size-meta, fill: luma(110))[(#pct(objective.rate) of #objective.items)]
]),
)
} else { [] },
if strengths.len() > 0 {
panel(
"Already solid",
ok-color,
[You showed these. Spend your review time elsewhere.],
strengths.map(objective => markup(objective.text)),
)
} else { [] },
)
]
// ─────────────────────────────────────────────────────────────────────────────
// Which lecture to go back to
// ─────────────────────────────────────────────────────────────────────────────
// One ranked lecture. Three fixed columns: the rank, the body, the count. The
// body is a stack, so the title, the provenance line, and the objectives are one
// step apart and no paragraph contributes spacing of its own.
#let lecture-entry(index, lecture) = {
let count = lecture.at("targets-missed", default: 0)
let numbers = lecture.at("questions", default: ())
let slides = lecture.at("slides", default: ())
let targets = lecture.at("targets", default: ())
let parts = ()
parts.push({
let url = lecture.at("url", default: none)
let title = text(weight: "bold")[#lecture.title]
[
#(if url != none { link(url)[#title] } else { title })
#text(size: size-meta, fill: luma(115))[(#lecture.lecture)]
]
})
// Slides and question numbers were two separate lines, one of them reached by
// a linebreak and one by a block. Together on one line they are easier to skim
// and the entry loses a ragged gap.
let trail = ()
if slides.len() > 0 {
trail.push(plural(slides.len(), "slide ", "slides ") + slides.map(str).join(", "))
}
if numbers.len() > 0 {
trail.push(
"you missed " + plural(numbers.len(), "question ", "questions ") + numbers.map(str).join(", "),
)
}
if trail.len() > 0 {
parts.push(text(size: size-meta, fill: luma(115))[#trail.join(" · ")])
}
// The learning targets, not the objectives they belong to. This section
// answers "what do I go and restudy", and a target is the grain that can be
// acted on: it names one performance rather than a whole claim.
//
// A real list rather than a middot glued to the front of a paragraph, so the
// second line of a long target indents under the first instead of running
// back to the margin.
if targets.len() > 0 {
parts.push({
set text(size: size-small, fill: luma(80))
list(
indent: 0pt,
body-indent: 0.45em,
spacing: step * 0.7,
marker: text(fill: luma(165))[·],
..targets.map(target => markup(target)),
)
})
}
block(breakable: false, above: entry-gap, width: 100%)[
#grid(
columns: (1.35em, 1fr, 5.2em),
column-gutter: 0.7em,
align: (left + top, left + top, right + top),
rank(index + 1, if index == 0 { bad-color } else if count > 1 { mid-color } else { accent }),
pad(right: prose-pad-inset)[#stack(dir: ttb, spacing: step, ..parts)],
text(size: size-micro, fill: luma(105))[
#count #plural(count, "target", "targets")
],
)
]
}
// ─────────────────────────────────────────────────────────────────────────────
#let review-lectures = cb-data.at("review-lectures", default: ())
#if show-lecture-plan and review-lectures.len() > 0 [
= Which lectures to go back to
#explain[
Each question connects to the lecture it came from. Sorting the lectures by how many separate things went wrong in each gives you an order to work through, hardest first. Under each lecture are the specific skills the questions were testing, so you can go to the part of it you need rather than rewatching the whole thing. Several from one lecture usually means an early idea did not land, and fixing that one is the cheapest repair.
]
#for (index, lecture) in review-lectures.enumerate() [
#lecture-entry(index, lecture)
]
]
// ─────────────────────────────────────────────────────────────────────────────
// Study plan
// ─────────────────────────────────────────────────────────────────────────────
#let study = cb-data.at("study", default: ())
#if show-study and study.len() > 0 [
= What to read
#explain[
Here are the sections from the course reading list that match the objectives above. Under each section, you'll find a brief sentence on what to focus on, so you don't need to reread the entire section.
]
#for group in study [
#block(breakable: false, above: entry-gap)[
=== #markup(group.text)
#for reading in group.readings [
#block(inset: (left: 0.8em), above: step)[
#{
let url = reading.at("url", default: none)
let cite = text(weight: "bold")[#reading.citation]
let parts = (
[
#(if url != none { link(url)[#cite] } else { cite })
#text(size: size-meta, fill: luma(110))[
· #reading.lecture-title (#reading.lecture)#{
if reading.at("supplemental", default: false) { ", optional" }
}
]
],
)
let focus-note = reading.at("focus", default: none)
if focus-note != none {
parts.push(pad(right: prose-pad-inset)[
#text(size: size-small)[#markup(focus-note)]
])
}
stack(dir: ttb, spacing: step * 0.6, ..parts)
}
]
]
]
]
]
// ─────────────────────────────────────────────────────────────────────────────
// Question map
// ─────────────────────────────────────────────────────────────────────────────
#let questions = cb-data.at("questions", default: ())
#let question-box(q) = {
let dropped = q.at("dropped", default: false)
let color = if dropped { luma(130) } else if q.at("blank", default: false) {
luma(160)
} else if q.at("correct", default: false) == true {
ok-color
} else if q.at("credit", default: 0.0) > 0.0 { mid-color } else { bad-color }
box(
width: 100%,
fill: color.lighten(85%),
stroke: 0.5pt + color.lighten(45%),
radius: 3pt,
inset: (x: 2pt, y: 4pt),
)[
#align(center)[
#text(size: size-small, weight: "bold", fill: color.darken(18%))[#q.number]
#{
if dropped {
linebreak()
text(size: size-tag, fill: luma(110))[out]
} else {
let level = q.at("level", default: none)
if level != none {
linebreak()
text(size: size-tag, fill: luma(120))[L#level]
}
}
}
]
]
}
#if show-question-map and questions.len() > 0 [
#block(breakable: false)[
= Question by question
#explain[
There is one box for each question, in the same order as on your exam paper. Each box is colored to show your result. The small *L* below each box shows the type of thinking required, matching the first table. The questions are not shown here, so use these numbers during office hours.
]
#grid(
columns: (1fr,) * 10,
column-gutter: 4pt,
row-gutter: 5pt,
..questions.map(question-box),
)
#v(0.6em)
#text(size: size-micro, fill: luma(110))[
#box(width: 0.7em, height: 0.7em, fill: ok-color.lighten(70%), radius: 2pt) right ·
#box(width: 0.7em, height: 0.7em, fill: mid-color.lighten(70%), radius: 2pt) part marks ·
#box(width: 0.7em, height: 0.7em, fill: bad-color.lighten(70%), radius: 2pt) not right ·
#box(width: 0.7em, height: 0.7em, fill: luma(210), radius: 2pt) left blank ·
#box(width: 0.7em, height: 0.7em, fill: luma(150), radius: 2pt) dropped, not scored
]
]
]
// ─────────────────────────────────────────────────────────────────────────────
// Notes on what was missed
// ─────────────────────────────────────────────────────────────────────────────
// One note. Four layers, in the order a student needs them: what the question
// was measuring, what went wrong, what to ask next time, and where to look it
// up. Each is a stack child, so every gap is one step and the provenance lines
// at the end sit tight together as a single footer.
#let note-entry(q) = {
let parts = ()
// Both tiers. The target is what this question actually asked, which is the
// thing to practise; the objective is the row it counted toward in the table
// above, which is how a student tells whether one slip cost them a claim. The
// objective is absent when the two would be the same sentence.
let measured = q.at("measured", default: ())
if measured.len() > 0 {
parts.push({
set text(fill: luma(21.57%))
stack(
dir: ttb,
spacing: step * 0.45,
..measured.map(m => {
let objective = m.at("objective", default: none)
let target = [
#text(weight: "bold")[This question asked you to:] #markup(m.target)
]
if objective == none {
target
} else {
stack(
dir: ttb,
spacing: step * 0.3,
target,
text(size: size-small, fill: luma(110))[
Counts toward: #markup(objective)
],
)
}
}),
)
})
}
// The diagnosis carries full body size. It is the sentence worth reading
// twice, and it was previously the same weight as the objective above it.
let feedback = q.at("feedback", default: none)
if feedback != none { parts.push(markup(feedback)) }
let hint = q.at("hint", default: none)
if hint != none { parts.push(callout("Try this", markup(hint))) }
// Present only with `--misconceptions`. This one is written to the instructor
// about the answer, which is why it reads differently.
let misconception = q.at("misconception", default: none)
if misconception != none {
parts.push(callout("The idea this option tests for", markup(misconception)))
}
// Present only with `--solutions`.
let worked = q.at("worked", default: none)
if worked != none {
parts.push(block(
width: 100%,
fill: luma(249),
radius: 3pt,
inset: (x: 8pt, y: 7pt),
)[
#stack(
dir: ttb,
spacing: step * 0.5,
text(size: size-meta, weight: "bold", fill: luma(85))[HOW IT WORKS OUT],
text(size: size-small)[#markup(worked)],
)
])
}
let trail = ()
let taught = q.at("taught-in", default: ())
if taught.len() > 0 { trail.push(meta-pair("Taught in", taught.join("; "))) }
let review = q.at("review", default: ())
if show-item-readings and review.len() > 0 {
let cites = review.map(reading => {
let url = reading.at("url", default: none)
let cite = reading.citation
if url != none { link(url)[#cite] } else { [#cite] }
})
trail.push(meta-pair("Read again", cites.join([ · ])))
}
if trail.len() > 0 {
parts.push(stack(dir: ttb, spacing: step * 0.4, ..trail))
}
block(breakable: false, above: entry-gap, width: 100%)[
#grid(
columns: (1.8em, 1fr),
column-gutter: 0.7em,
align: (right + top, left + top),
text(weight: "bold", fill: accent)[#str(q.number)],
pad(right: prose-pad-inset)[#stack(dir: ttb, spacing: step, ..parts)],
)
]
}
// ─────────────────────────────────────────────────────────────────────────────
#let missed = questions.filter(q => (
q.at("credit", default: 0.0) < 0.999
and (
q.at("feedback", default: none) != none
or q.at("hint", default: none) != none
or q.at("worked", default: none) != none
or q.at("review", default: ()).len() > 0
)
))
#if show-feedback and missed.len() > 0 [
= A closer look at the ones you missed
#explain[
Each note below explains the reasoning behind your answer instead of just giving the correct one. It helps you see where things went off track so you can avoid the same mistake next time. You can also use your paper to compare your answer with the question.
If you see a line starting with *try this*, it suggests a question to ask yourself as you review the problem. If a reading is listed, it shows which section it came from.
]
#for q in missed [
#note-entry(q)
]
]
// ─────────────────────────────────────────────────────────────────────────────
// Footer note
// ─────────────────────────────────────────────────────────────────────────────
#v(1.3em)
#line(length: 100%, stroke: 0.5pt + luma(210))
#v(0.45em)
#pad(right: prose-pad)[#text(size: size-micro, fill: luma(120))[
Built on #cb-meta.generator.on by #cb-meta.generator.tool #cb-meta.generator.version, from the #str(n-tested) #plural(n-tested, "student", "students") who took this exam. Because a percentage based on one or two questions can be highly uncertain, bring anything here that looks wrong or surprising to office hours. That is the best use you can make of this page.
]]
+7 -1
View File
@@ -21,7 +21,9 @@
//! 3. [`first_exam`] runs one exam end to end: assemble, export, administer,
//! ingest, analyze, report.
//! 4. [`typst_export`] covers printed output, template markers, and render config.
//! 5. [`recipes`] holds short answers to specific questions, for when you already
//! 5. [`assignment`] publishes a homework to the course website, with solutions
//! gated behind a password.
//! 6. [`recipes`] holds short answers to specific questions, for when you already
//! know the shape of the tool.
//!
//! ## Why the tutorials are in here rather than a wiki
@@ -55,6 +57,10 @@ pub mod first_exam {}
#[doc = include_str!("../docs/TYPST.md")]
pub mod typst_export {}
/// Publishing an assignment to the website, with solutions gated behind a password.
#[doc = include_str!("../docs/guide/assignment.md")]
pub mod assignment {}
/// Short answers to specific questions.
#[doc = include_str!("../docs/guide/recipes.md")]
pub mod recipes {}
+5 -5
View File
@@ -100,17 +100,17 @@ pub mod util;
pub use util::{date, hash, markup, rng, yaml, zipfile};
pub use model::{assessment, bank, catalog, course, history, item, layout, taxonomy};
pub use model::{assessment, bank, catalog, course, history, item, layout, seal, taxonomy};
pub use authoring::{jsonschema, lint, select};
#[cfg(feature = "parquet")]
pub use data::store_parquet;
pub use data::{canvas, gradescope, responses, store};
pub use data::{canvas, decode, gradescope, intake, responses, store};
pub use analysis::{calibrate, classical, irt, students};
pub use analysis::{calibrate, classical, diagnostic, irt, students};
pub use export::{qti, report, typst};
pub use export::site;
pub use export::{lecture, practice, qti, report, typst};
pub use catalog::Catalog;
pub use course::{CourseFile, SCHEMA_VERSION};
+1
View File
@@ -31,4 +31,5 @@ pub mod course;
pub mod history;
pub mod item;
pub mod layout;
pub mod seal;
pub mod taxonomy;
+98 -3
View File
@@ -268,9 +268,13 @@ pub struct Placement {
/// The level as administered, denormalized so a record reads standalone.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<Level>,
/// Objectives as administered, denormalized for the same reason.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec<String>,
/// Targets as administered, denormalized for the same reason.
#[serde(
default,
alias = "learning_objectives",
skip_serializing_if = "Vec::is_empty"
)]
pub learning_targets: Vec<String>,
/// Credit awarded to non-keyed options after the fact, keyed by letter.
///
/// When item analysis or a student challenge leads you to credit a
@@ -281,6 +285,83 @@ pub struct Placement {
/// Set when an item was dropped from scoring after administration.
#[serde(default, skip_serializing_if = "is_false")]
pub dropped: bool,
/// How the drop was applied on the grading platform.
///
/// Two ways to throw a question out, and they produce different
/// percentages. [`DropStyle::Removed`] takes the item out of the numerator
/// and the denominator: a student with 27 of 35 scores 77.1%.
/// [`DropStyle::FullCredit`] is what you do when the grade of record lives
/// somewhere else and the question cannot be removed from it: every option
/// is keyed, everyone earns the point, and the same student scores 28 of 36,
/// or 77.8%.
///
/// Defaults to [`DropStyle::Removed`], which is what `dropped: true` meant
/// before this field existed. Set it to `full_credit` when you have credited
/// every option in the platform, so that the report agrees with the grade the
/// student can see.
///
/// Either way the item is out of the item statistics, the objective
/// evidence, and the IRT fit: an item everyone got right has no variance to
/// contribute.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dropped_as: Option<DropStyle>,
/// Set only when the item was pulled *before* the paper was printed.
///
/// `dropped` on its own means what its own documentation says: the item was
/// printed, students answered it, and it was then taken out of scoring.
/// Such an item keeps its printed position, because it occupied one on the
/// page the students held, and the responses that come back are numbered
/// around it.
///
/// An item pulled before printing never occupied a position, so every later
/// question moves up one. That case has to be distinguished, and it cannot
/// be inferred: both look identical in the record. Getting it wrong is not
/// a cosmetic error. Sealing a post-administration drop as if it had never
/// been printed renumbers every question after it, so each response is
/// attributed to the wrong item, the statistics for those items are
/// computed from answers to different questions, and nothing in the output
/// looks obviously wrong.
///
/// Practically: leave this alone when you discover a bad question after the
/// exam, which is the common case. Set it when you cut a question from the
/// draft and reprinted.
#[serde(default, skip_serializing_if = "is_false")]
pub dropped_before_printing: bool,
}
/// How a dropped item was handled on the grading platform.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DropStyle {
/// Taken out of the numerator and the denominator.
Removed,
/// Every option credited, so the item stays in both.
FullCredit,
}
impl Placement {
/// Whether this placement was dropped by crediting every option.
///
/// # Returns
///
/// `true` only when the item is dropped *and* the drop was applied as full
/// credit, so the item still belongs in the points of record.
pub fn dropped_with_credit(&self) -> bool {
self.dropped && self.dropped_as == Some(DropStyle::FullCredit)
}
/// Whether this placement occupied a printed position on the paper.
///
/// Everything that lays out a page or reads a page back goes through this,
/// so the printed form, the seal, and the decoder cannot disagree about
/// which question sat where.
///
/// # Returns
///
/// `true` unless the item was pulled before printing.
pub fn was_printed(&self) -> bool {
!(self.dropped && self.dropped_before_printing)
}
}
impl AssessmentFile {
@@ -451,6 +532,20 @@ impl AssessmentFile {
));
}
}
if p.dropped_as.is_some() && !p.dropped {
issues.push(format!(
"question {}: `dropped_as` is set but `dropped` is not, so nothing is dropped",
p.number
));
}
if p.dropped && !p.credit_overrides.is_empty() {
issues.push(format!(
"question {}: dropped and carrying credit overrides. Pick one: an override \
rescores an option, a drop removes the question",
p.number
));
}
}
let mut form_ids: Vec<&str> = Vec::new();
+221 -43
View File
@@ -79,7 +79,7 @@ pub struct BankMeta {
/// What a bank is scoped to.
///
/// A bank may be scoped by lecture, by objective, by topic, or by none of them.
/// A bank may be scoped by lecture, by learning target, by topic, or by none of them.
/// Declaring the scope is what lets the catalog report *gaps*: it can only tell
/// you that lecture 12 has no Apply-level items if it knows lecture 12 is
/// supposed to be covered here.
@@ -89,9 +89,13 @@ pub struct Scope {
/// Lectures this bank draws from.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lectures: Vec<String>,
/// Objectives this bank is responsible for covering.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec<String>,
/// Learning targets this bank is responsible for covering.
#[serde(
default,
alias = "learning_objectives",
skip_serializing_if = "Vec::is_empty"
)]
pub learning_targets: Vec<String>,
/// Units this bank belongs to.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub units: Vec<String>,
@@ -254,9 +258,9 @@ impl BankFile {
issues.push(format!("bank.scope: unknown lecture `{lec}`"));
}
}
for lo in &self.bank.scope.learning_objectives {
if !c.learning_objectives.contains_key(lo) {
issues.push(format!("bank.scope: unknown learning objective `{lo}`"));
for target in &self.bank.scope.learning_targets {
if !c.is_target(target) && !c.is_objective(target) {
issues.push(format!("bank.scope: unknown learning target `{target}`"));
}
}
}
@@ -352,10 +356,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 +432,7 @@ fn validate_item(
}
}
// --- key ---------------------------------------------------------------
// --- key ---
let keys = it.key_indices();
match it.format {
Format::SingleBestAnswer => {
@@ -448,9 +462,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 +480,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 +498,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 +533,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 +552,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,15 +560,15 @@ 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 {
if it.cognitive_process.is_none() {
issues.push("approved items must declare a cognitive_process".into());
}
if it.learning_objectives.is_empty() {
issues.push("approved items must reference at least one learning objective".into());
if it.learning_targets.is_empty() {
issues.push("approved items must reference at least one learning target".into());
}
if it.sources.is_empty() {
issues.push("approved items must cite at least one source".into());
@@ -557,30 +576,82 @@ 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) {
None => issues.push(format!("unknown learning objective `{lo}`")),
Some(obj) => {
if let Some(ceiling) = obj.level_ceiling {
if it.level > ceiling {
issues.push(format!(
"level {} exceeds the ceiling {} declared for objective `{lo}`",
it.level.code(),
ceiling.code()
));
}
}
if !obj.assessed {
issues.push(format!(
"objective `{lo}` is marked `assessed: false` but this item measures it"
));
}
for tag in &it.learning_targets {
let is_target = c.is_target(tag);
if !is_target && !c.is_objective(tag) {
issues.push(format!("unknown learning target `{tag}`"));
continue;
}
// Items are tagged at the target tier. Tagging an objective that has
// targets would put the item in that objective's denominator without
// recording which performance the question actually asked for, and
// that record is what a report drills into and what coverage
// analysis counts. An objective with no targets stands as its own.
let its_targets = c.targets(tag);
if !its_targets.is_empty() {
issues.push(format!(
"`{tag}` is an objective with {} learning target(s); tag the specific \
target this item measures instead",
its_targets.len()
));
}
// The ceiling may be inherited from the objective, so a target that
// declares none of its own is still bounded.
if let Some(ceiling) = c.effective_level_ceiling(tag) {
if it.level > ceiling {
let declares_its_own = if is_target {
c.learning_targets
.get(tag)
.is_some_and(|t| t.level_ceiling.is_some())
} else {
true
};
let source = if declares_its_own {
format!("declared for `{tag}`")
} else {
format!(
"inherited by `{tag}` from its objective `{}`",
c.objective_for(tag)
)
};
issues.push(format!(
"level {} exceeds the ceiling {} {source}",
it.level.code(),
ceiling.code()
));
}
}
if !c.is_assessed(tag) {
let parked_above =
is_target && c.learning_targets.get(tag).is_some_and(|t| t.assessed);
let which = if parked_above {
format!(
"its objective `{}` is marked `assessed: false`",
c.objective_for(tag)
)
} else {
format!("`{tag}` is marked `assessed: false`")
};
issues.push(format!("{which} but this item measures it"));
}
}
for s in &it.sources {
if !c.lectures.contains_key(&s.lecture) {
@@ -592,6 +663,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 +724,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_targets: [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(
@@ -742,7 +869,7 @@ mod tests {
let issues = b.validate(None);
for want in [
"cognitive_process",
"learning objective",
"learning target",
"source",
"design block",
] {
@@ -829,7 +956,7 @@ learning_objectives:
status: draft
level: 4
stem: s
learning_objectives: [lo-known, lo-unknown]
learning_targets: [lo-known, lo-unknown]
sources: [{ lecture: L99 }]
options:
- { id: A, text: a, correct: true }
@@ -840,7 +967,7 @@ learning_objectives:
assert!(
issues
.iter()
.any(|i| i.contains("unknown learning objective `lo-unknown`"))
.any(|i| i.contains("unknown learning target `lo-unknown`"))
);
assert!(issues.iter().any(|i| i.contains("unknown lecture `L99`")));
assert!(
@@ -849,6 +976,57 @@ learning_objectives:
);
}
#[test]
fn an_item_must_tag_a_target_not_an_objective_that_has_them() {
let course: CourseFile = serde_yaml_ng::from_str(
r#"
course: { code: X, title: Y, term: Z }
learning_objectives:
lo-binding: { text: Quantify binding., level_ceiling: 3 }
lo-solo: { text: An objective with no targets. }
learning_targets:
t-kd: { text: Write the expression., objective: lo-binding }
"#,
)
.unwrap();
let b = bank(
r#"
- id: q-a-001
status: draft
level: 1
stem: s
learning_targets: [lo-binding]
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
- id: q-a-002
status: draft
level: 1
stem: s
learning_targets: [t-kd, lo-solo]
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
"#,
);
let issues = b.validate(Some(&course));
assert!(
issues
.iter()
.any(|i| i.contains("`lo-binding` is an objective with 1 learning target(s)")),
"got {issues:?}"
);
// A target is fine, and so is an objective that has no targets: it
// stands as its own, which is what lets a course migrate a unit at a
// time.
assert!(
!issues
.iter()
.any(|i| i.contains("`t-kd`") || i.contains("`lo-solo`")),
"got {issues:?}"
);
}
#[test]
fn history_versions_must_increase() {
let b = bank(
@@ -879,7 +1057,7 @@ learning_objectives:
level: 1
cognitive_process: recall
stem: s
learning_objectives: [lo]
learning_targets: [lo]
sources: [{ lecture: L1 }]
design: { expected_difficulty: 0.8 }
options:
@@ -898,7 +1076,7 @@ learning_objectives:
cognitive_process: generate
bonus: true
stem: s
learning_objectives: [lo]
learning_targets: [lo]
sources: [{ lecture: L1 }]
design: { expected_difficulty: 0.3 }
options:
+168 -33
View File
@@ -26,7 +26,7 @@ use crate::course::CourseFile;
use crate::error::{Error, Result};
use crate::item::Item;
use crate::layout::Layout;
use crate::taxonomy::{Level, Status};
use crate::taxonomy::{Level, Status, Tier};
use crate::yaml;
/// One item plus everything needed to locate it again.
@@ -321,19 +321,19 @@ impl Catalog {
out
}
/// Items that measure a given objective.
/// Items tagged with a given learning target.
///
/// # Arguments
///
/// * `objective` - the objective id.
/// * `target` - the target id.
///
/// # Returns
///
/// Matching entries.
pub fn by_objective(&self, objective: &str) -> Vec<&Entry> {
pub fn by_target(&self, target: &str) -> Vec<&Entry> {
self.entries
.iter()
.filter(|e| e.item.learning_objectives.iter().any(|o| o == objective))
.filter(|e| e.item.learning_targets.iter().any(|t| t == target))
.collect()
}
@@ -384,55 +384,124 @@ impl Catalog {
out
}
/// Builds the coverage report.
/// Items that measure a given objective, through any of its targets.
///
/// # Arguments
///
/// * `objective` - the objective id.
///
/// # Returns
///
/// One row per assessed objective plus a list of course-wide gaps.
/// Entries tagged with the objective itself or with any of its targets,
/// each appearing once even when it is tagged with two of them.
pub fn by_objective(&self, objective: &str) -> Vec<&Entry> {
self.entries
.iter()
.filter(|e| {
e.item
.learning_targets
.iter()
.any(|t| t == objective || self.course.objective_for(t) == objective)
})
.collect()
}
/// Builds the coverage report.
///
/// Rows cover both tiers, because the two answer different questions. An
/// objective row answers "can I build an exam that reports on this
/// objective", and aggregates every item under it. A target row answers
/// "which specific things have I written items for", which is the
/// authoring queue, and a target with no items is the most common and least
/// visible hole in a bank: the objective looks well covered while a third of
/// what it claims has never been asked.
///
/// # Returns
///
/// One row per assessed entry plus a list of course-wide gaps.
pub fn coverage(&self) -> Coverage {
let mut rows = Vec::new();
for id in self.course.objectives_in_order() {
let obj = &self.course.learning_objectives[&id];
if !obj.assessed {
for id in self.course.registry_in_order() {
if !self.course.is_objective(&id) && !self.course.is_target(&id) {
continue;
}
let items = self.by_objective(&id);
if !self.course.is_assessed(&id) {
continue;
}
let targets = self.course.targets(&id);
let tier = if self.course.is_objective(&id) {
Tier::Objective
} else {
Tier::Target
};
// An objective's pool is everything under it; a target's is what is
// tagged to it directly. An objective with no targets is its own
// target, so the two agree there.
let items = if targets.is_empty() {
self.by_target(&id)
} else {
self.by_objective(&id)
};
let usable: Vec<&&Entry> = items.iter().filter(|e| e.item.is_assemblable()).collect();
let mut levels: BTreeSet<Level> = BTreeSet::new();
for e in &usable {
levels.insert(e.item.level);
}
let targets_covered = targets
.iter()
.filter(|target| {
self.by_target(target)
.iter()
.any(|e| e.item.is_assemblable())
})
.count();
rows.push(CoverageRow {
objective: id.clone(),
text: obj.text.clone(),
unit: obj.unit.clone(),
id: id.clone(),
text: self.course.text_for(&id),
tier,
objective: self
.course
.is_target(&id)
.then(|| self.course.objective_for(&id).to_string()),
unit: self.course.objective_unit(&id).map(str::to_string),
targets: targets.len(),
targets_covered,
total: items.len(),
assemblable: usable.len(),
max_level: levels.iter().next_back().copied(),
levels: levels.into_iter().collect(),
ceiling: obj.level_ceiling,
ceiling: self.course.effective_level_ceiling(&id),
});
}
let mut gaps = Vec::new();
for row in &rows {
// Gaps are raised against the tier that can be acted on. "No items
// at all" is worth saying about a target, because writing one is the
// fix. "Resting on a single item" is worth saying about an
// objective, because a target resting on one item is the normal and
// intended case, and flagging two hundred of them would bury the
// rows that matter.
if row.total == 0 {
gaps.push(Gap::Uncovered(row.objective.clone()));
gaps.push(Gap::Uncovered(row.id.clone()));
} else if row.assemblable == 0 {
gaps.push(Gap::NoApprovedItems(row.objective.clone()));
} else if row.assemblable == 1 {
gaps.push(Gap::SingleItem(row.objective.clone()));
gaps.push(Gap::NoApprovedItems(row.id.clone()));
} else if row.assemblable == 1 && row.tier == Tier::Objective {
gaps.push(Gap::SingleItem(row.id.clone()));
}
// An objective assessed only at the recall level is the most common
// and most consequential blind spot: it looks covered in a count and
// is not covered in fact.
if row.assemblable > 0 && row.max_level == Some(Level::Remember) {
if row.tier == Tier::Objective
&& row.assemblable > 0
&& row.max_level == Some(Level::Remember)
{
if let Some(ceiling) = row.ceiling {
if ceiling > Level::Remember {
gaps.push(Gap::RecallOnly(row.objective.clone()));
gaps.push(Gap::RecallOnly(row.id.clone()));
}
} else {
gaps.push(Gap::RecallOnly(row.objective.clone()));
gaps.push(Gap::RecallOnly(row.id.clone()));
}
}
}
@@ -451,7 +520,7 @@ impl Catalog {
// Items with no objective at all cannot appear in any student report.
for e in &self.entries {
if e.item.learning_objectives.is_empty() && e.item.is_assemblable() {
if e.item.learning_targets.is_empty() && e.item.is_assemblable() {
gaps.push(Gap::ItemWithoutObjective(e.uid.clone()));
}
}
@@ -463,21 +532,35 @@ impl Catalog {
/// The coverage report.
#[derive(Debug, Clone)]
pub struct Coverage {
/// One row per assessed objective.
/// One row per assessed registry entry, objectives first with their targets
/// following each.
pub rows: Vec<CoverageRow>,
/// Course-wide gaps worth acting on.
pub gaps: Vec<Gap>,
}
/// Coverage of one objective.
/// Coverage of one registry entry, at either tier.
#[derive(Debug, Clone)]
pub struct CoverageRow {
/// The objective id.
pub objective: String,
/// The objective text.
/// The registry id.
pub id: String,
/// Its text.
pub text: String,
/// The unit it belongs to.
/// Whether this is an objective, whose counts aggregate every item under it,
/// or a target, whose counts are its own items.
pub tier: Tier,
/// The objective this row sits under, for a target.
pub objective: Option<String>,
/// The unit it belongs to, inherited from the objective when not declared.
pub unit: Option<String>,
/// Targets in the registry, for an objective row.
pub targets: usize,
/// Targets with at least one usable item.
///
/// The number to look at when an objective looks well covered: twenty items
/// spread over four of its nine targets is a different bank from twenty
/// items spread over all nine.
pub targets_covered: usize,
/// Items referencing it, at any status.
pub total: usize,
/// Items that could actually be used.
@@ -486,16 +569,16 @@ pub struct CoverageRow {
pub max_level: Option<Level>,
/// Every level assessed.
pub levels: Vec<Level>,
/// The declared ceiling, when set.
/// The ceiling in force, inherited from the objective when not declared.
pub ceiling: Option<Level>,
}
/// A specific, actionable hole in the item pool.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Gap {
/// An assessed objective with no items at all.
/// An assessed objective or target with no items at all.
Uncovered(String),
/// An objective whose items are all drafts or retired.
/// An entry whose items are all drafts or retired.
NoApprovedItems(String),
/// An objective resting on a single item, so one bad item hides it entirely.
SingleItem(String),
@@ -606,7 +689,7 @@ items:
level: 1
cognitive_process: recall
stem: What is x?
learning_objectives: [lo-covered]
learning_targets: [lo-covered]
sources: [{ lecture: L01 }]
design: { expected_difficulty: 0.8 }
options:
@@ -672,6 +755,58 @@ items:
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn coverage_aggregates_targets_and_names_the_untested_ones() {
let dir = tmp("tiered-coverage");
std::fs::create_dir_all(dir.join("banks")).unwrap();
std::fs::write(
dir.join("course.yaml"),
r#"
course: { code: TEST 101, title: Testing, term: Fall 2026 }
lectures:
L01: { title: One }
learning_objectives:
lo-binding: { text: Quantify binding., lectures: [L01], order: 1, level_ceiling: 3 }
learning_targets:
t-kd: { text: Write the expression., objective: lo-binding, order: 1 }
t-plot: { text: Read a plot., objective: lo-binding, order: 2 }
"#,
)
.unwrap();
// Two items, both on the same target.
let bank = APPROVED.replace("lo-covered", "t-kd").replace(
" - { id: B, text: wrong }\n",
" - { id: B, text: wrong }\n - id: q-x-002\n status: approved\n level: 3\n \
cognitive_process: implement\n stem: And again?\n learning_targets: [t-kd]\n \
sources: [{ lecture: L01 }]\n design: { expected_difficulty: 0.5 }\n options:\n \
- { id: A, text: right, correct: true }\n - { id: B, text: wrong }\n",
);
write_bank(&dir, "b1.yaml", &bank);
let cat = Catalog::load(&dir).unwrap();
let cov = cat.coverage();
let objective = cov
.rows
.iter()
.find(|r| r.id == "lo-binding")
.expect("the objective row");
assert_eq!(objective.tier, Tier::Objective);
assert_eq!(
objective.assemblable, 2,
"the objective aggregates its targets"
);
assert_eq!(
(objective.targets_covered, objective.targets),
(1, 2),
"two items, but only one of the two targets has been asked about"
);
// Resting on one item is the normal case for a target, so it is not a
// gap; never having been asked about at all is.
assert!(!cov.gaps.contains(&Gap::SingleItem("t-kd".into())));
assert!(cov.gaps.contains(&Gap::Uncovered("t-plot".into())));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn recall_only_respects_a_recall_ceiling() {
let dir = tmp("ceiling");
+1827 -25
View File
File diff suppressed because it is too large Load Diff
+330 -9
View File
@@ -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,11 +83,38 @@ 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<Choice>,
/// Objectives this item measures, as ids into the course registry.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec<String>,
/// 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<Solution>,
/// The learning targets this item measures, as ids into the course
/// registry.
///
/// Targets rather than objectives: an item measures one specific
/// performance, and recording which one is what lets a report explain an
/// objective's result instead of only stating it. The objective follows from
/// the target, so it is never recorded twice.
#[serde(
default,
alias = "learning_objectives",
skip_serializing_if = "Vec::is_empty"
)]
pub learning_targets: Vec<String>,
/// Where the material was taught.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -93,7 +124,7 @@ pub struct Item {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub topics: Vec<String>,
/// Item ids or objective ids a student needs before this is fair.
/// Item ids or registry ids a student needs before this is fair.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prerequisites: Vec<String>,
@@ -239,6 +270,214 @@ pub struct Source {
pub recording_seconds: Option<u32>,
}
/// 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<String>,
/// Where inside the work: `§6.1`, `pp. 212-219`, `fig. 4`.
pub locator: Option<String>,
/// Appended to the reference's `base_url` to reach this location.
pub path: Option<String>,
/// A full URL, when the location is not under the reference's `base_url`.
pub url: Option<String>,
/// A citation written as a bare string, held unparsed.
pub text: Option<String>,
}
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<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
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: Deserializer<'de>>(d: D) -> std::result::Result<Citation, D::Error> {
/// 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<String>,
#[serde(default)]
locator: Option<String>,
#[serde(default)]
path: Option<String>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
text: Option<String>,
}
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<E: de::Error>(self, v: &str) -> std::result::Result<Citation, E> {
Ok(Citation {
text: Some(v.to_string()),
..Citation::default()
})
}
fn visit_map<M: MapAccess<'a>>(
self,
map: M,
) -> std::result::Result<Citation, M::Error> {
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<f64>,
}
/// 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<String>,
/// 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<String>,
/// How an open response is graded, one criterion per line.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rubric: Vec<RubricCriterion>,
/// 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<String>,
/// 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<Citation>,
}
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<f64> {
if self.rubric.is_empty() {
return None;
}
self.rubric.iter().map(|c| c.points).sum::<Option<f64>>()
}
}
/// A figure or data file reproduced with an item.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -442,7 +681,7 @@ impl Item {
/// the only way to get a half-built `Item` is deliberately.
///
/// The result is `Status::Draft` and deliberately will not pass
/// [`Item::is_assemblable`] — it still needs learning objectives, sources, and
/// [`Item::is_assemblable`] — it still needs learning targets, sources, and
/// a cognitive process before it can be drawn onto an assessment.
///
/// # Arguments
@@ -473,7 +712,8 @@ impl Item {
stimulus: None,
stem: stem.to_string(),
options,
learning_objectives: Vec::new(),
solution: None,
learning_targets: Vec::new(),
sources: Vec::new(),
topics: Vec::new(),
prerequisites: Vec::new(),
@@ -538,6 +778,14 @@ impl Item {
self.key_indices().len() > 1
}
/// Whether the item presents selectable options, per its [`Format`].
///
/// `false` for an [`Format::OpenResponse`] item. Callers that would otherwise
/// index `options` or read a key should branch on this first.
pub fn has_options(&self) -> bool {
self.format.has_options()
}
/// The display title, falling back to a truncated stem.
///
/// # Returns
@@ -560,7 +808,7 @@ impl Item {
/// A content fingerprint over everything that affects what a student sees.
///
/// Metadata deliberately does not contribute: retagging an objective must not
/// Metadata deliberately does not contribute: retagging a target must not
/// invalidate pooled statistics, but rewording an option must.
///
/// # Returns
@@ -726,7 +974,7 @@ options:
let base = item(MINIMAL);
let mut retagged = base.clone();
retagged.topics = vec!["kinetics".into()];
retagged.learning_objectives = vec!["lo-a".into()];
retagged.learning_targets = vec!["lo-a".into()];
retagged.author = Some("someone".into());
assert_eq!(
base.fingerprint(),
@@ -821,4 +1069,77 @@ options:
IrtModel::ThreePl
);
}
#[test]
fn open_response_item_parses_without_options() {
let it = item(
r#"
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.
solution:
model_answer: >-
At constant pressure the P dV expansion work is folded into H = U + PV, so the
heat q_p equals the change in H.
rubric:
- { description: "states H = U + PV", points: 1 }
- { description: "identifies q_p with the enthalpy change", points: 1 }
review:
- { ref: kuriyan2012molecules, locator: "§6.4", path: "6/A/#4" }
"#,
);
assert_eq!(it.format, Format::OpenResponse);
assert!(it.options.is_empty());
assert!(!it.has_options());
assert!(it.key_letters().is_empty());
let sol = it.solution.as_ref().expect("has a solution");
assert!(!sol.is_empty());
assert_eq!(sol.rubric_points(), Some(2.0));
assert_eq!(sol.review.len(), 1);
assert_eq!(
sol.review[0].reference.as_deref(),
Some("kuriyan2012molecules")
);
}
#[test]
fn a_solution_serializes_only_what_it_holds() {
let it = item(
r#"
id: q-demo-op-002
status: draft
level: 2
format: open_response
stem: State the first law.
solution:
model_answer: The total energy of an isolated system is constant.
"#,
);
let yaml = serde_yaml_ng::to_string(&it).expect("serializes");
// Open-response items carry no options key, and an empty rubric is omitted.
assert!(!yaml.contains("options:"), "no options key:\n{yaml}");
assert!(!yaml.contains("rubric"), "empty rubric omitted:\n{yaml}");
assert!(yaml.contains("model_answer:"));
}
#[test]
fn a_citation_round_trips_as_string_or_mapping() {
// A bare string stays a bare string.
let bare: Citation = serde_yaml_ng::from_str("\"KKW §6.4 (course reserve)\"").unwrap();
assert_eq!(bare.text.as_deref(), Some("KKW §6.4 (course reserve)"));
assert_eq!(bare.display(), "KKW §6.4 (course reserve)");
let back = serde_yaml_ng::to_string(&bare).unwrap();
assert_eq!(back.trim(), "KKW §6.4 (course reserve)");
// A mapping keeps its fields, and `ref` is the key's YAML spelling.
let mapped: Citation =
serde_yaml_ng::from_str("{ ref: kuriyan2012molecules, locator: \"§6.4\" }").unwrap();
assert_eq!(mapped.reference.as_deref(), Some("kuriyan2012molecules"));
assert_eq!(mapped.display(), "kuriyan2012molecules §6.4");
let back = serde_yaml_ng::to_string(&mapped).unwrap();
assert!(back.contains("ref: kuriyan2012molecules"));
assert!(back.contains("locator:"));
}
}
+9
View File
@@ -76,6 +76,15 @@ impl Layout {
self.root.join("templates")
}
/// Directory holding sealed administrations.
///
/// Deliberately not `assessments/`:
/// [`crate::assessment::AssessmentFile::load_all`] parses every `.yaml` in
/// that directory, and a seal is not an assessment record.
pub fn seals(&self) -> PathBuf {
self.root.join(crate::seal::SEAL_DIR)
}
/// Creates every directory in the layout.
///
/// # Errors
+1311
View File
File diff suppressed because it is too large Load Diff
+88
View File
@@ -9,6 +9,44 @@ use std::fmt;
use serde::{Deserialize, Serialize};
/// Which tier of the objective registry an entry or a reported row belongs to.
///
/// The two words come from the assessment literature and name two different
/// jobs, not two sizes of the same thing. A **learning objective** is what a
/// syllabus lists and what a report classifies as met: the unit a claim is made
/// about. A **learning target** is the specific performance an item is written
/// against and tagged to: what a student aims at in one class, and the evidence
/// a claim about an objective rests on.
///
/// It lives here rather than beside the registry because every layer that
/// reports needs it, and a `bool` named for one of the two tiers leaves the
/// reader guessing which way round it points.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Tier {
/// A learning objective: the tier a mastery claim is made about.
Objective,
/// A learning target: the tier items are tagged to, and evidence for the
/// objective above it.
Target,
}
impl Tier {
/// The snake_case token used in YAML and in flat storage.
pub fn as_str(self) -> &'static str {
match self {
Tier::Objective => "objective",
Tier::Target => "target",
}
}
}
impl fmt::Display for Tier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// Cognitive demand, following the revised Bloom taxonomy.
///
/// Serialized as the integers 1 through 5 so YAML reads `level: 3`. The derived
@@ -423,17 +461,53 @@ pub enum Format {
MultipleResponse,
/// Two options, True and False.
TrueFalse,
/// A free-text answer the student writes rather than selects.
///
/// It carries no options and is not machine-scored. What a grader marks it
/// against, and what a solutions document shows, lives in the item's
/// [`crate::item::Solution`]: a model answer and, when the item is worth more
/// than a point, a rubric. This is the format for "explain", "derive", and
/// "estimate" prompts that a set of distractors would trivialize.
OpenResponse,
}
impl Format {
/// Every response format.
pub const ALL: [Format; 4] = [
Format::SingleBestAnswer,
Format::MultipleResponse,
Format::TrueFalse,
Format::OpenResponse,
];
/// The QTI question type Canvas expects for this format.
pub fn qti_type(self) -> &'static str {
match self {
Format::SingleBestAnswer => "multiple_choice_question",
Format::MultipleResponse => "multiple_answers_question",
Format::TrueFalse => "true_false_question",
Format::OpenResponse => "essay_question",
}
}
/// The snake_case token used in YAML.
pub fn as_str(self) -> &'static str {
match self {
Format::SingleBestAnswer => "single_best_answer",
Format::MultipleResponse => "multiple_response",
Format::TrueFalse => "true_false",
Format::OpenResponse => "open_response",
}
}
/// Whether items in this format present selectable options.
///
/// `false` only for [`Format::OpenResponse`]. Validation, assembly, and the
/// exporters branch on this rather than on the variant, so the day a second
/// free-text format is added it inherits the no-options handling for free.
pub fn has_options(self) -> bool {
!matches!(self, Format::OpenResponse)
}
}
/// How strongly an item is expected to separate strong from weak students.
@@ -633,4 +707,18 @@ mod tests {
Status::InReview
);
}
#[test]
fn open_response_is_the_only_format_without_options() {
assert_eq!(
serde_json::from_str::<Format>("\"open_response\"").unwrap(),
Format::OpenResponse
);
assert_eq!(Format::OpenResponse.as_str(), "open_response");
assert_eq!(Format::OpenResponse.qti_type(), "essay_question");
assert!(!Format::OpenResponse.has_options());
for f in Format::ALL {
assert_eq!(f.has_options(), f != Format::OpenResponse);
}
}
}
+455 -9
View File
@@ -113,10 +113,53 @@ pub fn to_plain(src: &str) -> String {
out.trim().to_string()
}
/// Passes authoring markup through for Typst.
/// Converts authoring markup to Pandoc-flavoured Markdown, for a Quarto document.
///
/// The markup is already a Typst subset, so this only normalizes whitespace and
/// escapes the few characters Typst treats specially in content mode.
/// Subscripts and superscripts become Pandoc's `~x~` and `^x^`, the symbol table
/// renders as Unicode, and the bold, italic, and inline-code spans are already
/// Markdown, so they pass through unchanged. Paragraph breaks are kept. Nothing is
/// HTML-escaped, because the consumer is a Markdown renderer rather than a page.
///
/// # Arguments
///
/// * `src` - the authoring source.
///
/// # Returns
///
/// Pandoc Markdown, trimmed, with paragraph breaks preserved.
pub fn to_markdown(src: &str) -> String {
let symbolized = apply_symbols(src, false);
let mut out = wrap_bracket(&symbolized, "#sub[", "~", "~");
out = wrap_bracket(&out, "#sup[", "^", "^");
out.lines()
.map(|l| l.trim_end())
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string()
}
/// Passes authoring markup through for Typst, translating inline LaTeX math on
/// the way.
///
/// 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")`. mhchem's `\ce{...}` is the one exception, and goes to
/// whalogen's `ce` instead, via `push_math_span`. 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
///
@@ -126,17 +169,286 @@ pub fn to_plain(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];
// A `\ce{...}` written outside math is still chemistry and still cannot
// reach Typst as a backslash: `\c` is an escape in content mode. Checked
// before the escaped-pair rule below, which would otherwise copy `\c`
// through and leave `e{...}` as literal text. The cheap two-character
// guard keeps a stem full of `\Delta` and `\times` from rescanning the
// remainder at every backslash.
if c == '\\'
&& chars.get(i + 1).map(|c| c.1) == Some('c')
&& chars.get(i + 2).map(|c| c.1) == Some('e')
{
if let Some(span) = find_ce(&src[chars[i].0..]) {
if span.start == 0 {
let at = chars[i].0;
push_ce(&mut out, &src[at + span.arg.0..at + span.arg.1]);
i = char_index(&chars, at + span.end);
continue;
}
}
}
// 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;
push_math_span(&mut out, &src[start..end]);
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<usize> {
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
}
/// The import line a template needs before it can receive a `#ce(...)` call.
///
/// mitex renders the LaTeX in a stem, but it ships no package support, so
/// mhchem's `\ce` is simply an unknown command to it. whalogen is a Typst port
/// of mhchem, and `ce` is the function this module emits chemistry as.
pub const CHEM_IMPORT: &str = "#import \"@preview/whalogen:0.3.0\": ce";
/// Whether `body` needs [`CHEM_IMPORT`] and `template` does not provide it.
///
/// A template is checked for the package name rather than for the exact import
/// line, so a template that pins a different whalogen version, imports the
/// package under an alias, or defines its own `ce` is left alone.
///
/// # Arguments
///
/// * `template` - the template source the body will be injected into.
/// * `body` - the generated Typst source.
///
/// # Returns
///
/// Whether the pair would fail to compile for want of the import.
pub fn needs_chem_import(template: &str, body: &str) -> bool {
body.contains("#ce(") && !template.contains("whalogen") && !template.contains("let ce")
}
/// A `\ce{...}` command located in a source fragment, as byte offsets from the
/// start of that fragment.
struct CeSpan {
/// Offset of the backslash.
start: usize,
/// Offset just past the closing brace.
end: usize,
/// The argument, without its braces.
arg: (usize, usize),
}
/// Finds the first `\ce{...}` in `s`.
///
/// Three details matter, and all three come from the same place: the scan has to
/// agree with what LaTeX itself would read.
///
/// A backslash-escaped pair is skipped as a unit, so the line break `\\`
/// followed by the letters `ce` is not read as the command. `$a \\ ce{x}$` is a
/// break and then literal text; mhchem's own command is a single backslash.
///
/// The name must end at the `e`, so `\cellcolor` and `\century` are left for
/// mitex rather than half-consumed here.
///
/// The argument is matched on brace depth rather than on the first `}`, so
/// `\ce{Fe^{2+}}` keeps its superscript. An unbalanced argument yields `None`:
/// there is nothing safe to convert, and mitex's own error names the line.
fn find_ce(s: &str) -> Option<CeSpan> {
let chars: Vec<(usize, char)> = s.char_indices().collect();
let mut i = 0;
while i < chars.len() {
if chars[i].1 != '\\' {
i += 1;
continue;
}
if matches!(chars.get(i + 1), Some((_, '\\'))) {
i += 2;
continue;
}
let named = chars.get(i + 1).map(|c| c.1) == Some('c')
&& chars.get(i + 2).map(|c| c.1) == Some('e')
&& !matches!(chars.get(i + 3), Some((_, c)) if c.is_ascii_alphabetic());
if !named {
i += 1;
continue;
}
// LaTeX allows whitespace between a control word and its argument.
let mut open = i + 3;
while matches!(chars.get(open), Some((_, c)) if c.is_whitespace()) {
open += 1;
}
if !matches!(chars.get(open), Some((_, '{'))) {
i += 1;
continue;
}
let mut depth = 1usize;
let mut k = open + 1;
while k < chars.len() {
match chars[k].1 {
'\\' => k += 2,
'{' => {
depth += 1;
k += 1;
}
'}' => {
depth -= 1;
if depth == 0 {
return Some(CeSpan {
start: chars[i].0,
end: chars[k].0 + 1,
arg: (chars[open].0 + 1, chars[k].0),
});
}
k += 1;
}
_ => k += 1,
}
}
return None;
}
None
}
/// The index into `chars` of the entry at byte offset `byte`, or the length when
/// the offset is past the end.
fn char_index(chars: &[(usize, char)], byte: usize) -> usize {
chars
.iter()
.position(|(at, _)| *at >= byte)
.unwrap_or(chars.len())
}
/// Emits one `$...$` span as Typst content.
///
/// Most of a span goes to mitex's `mi`, which parses LaTeX on purpose. The
/// exception is mhchem: `\ce{...}` is not a LaTeX primitive but a package
/// command with its own character-level grammar, and mitex implements no
/// packages, so the plugin aborts the whole document with `unknown command:
/// \ce` rather than degrading to something printable. Those runs are handed to
/// whalogen's `ce` instead and the rest of the span still goes to `mi`, so an
/// equation that mixes chemistry with ordinary math renders both.
///
/// A span with no chemistry in it produces exactly one `mi` call, as it always
/// has.
///
/// # Arguments
///
/// * `out` - the buffer to append to.
/// * `latex` - the LaTeX between the dollar signs.
fn push_math_span(out: &mut String, latex: &str) {
if find_ce(latex).is_none() {
push_mi(out, latex);
return;
}
let mut rest = latex;
while let Some(span) = find_ce(rest) {
push_run(out, &rest[..span.start]);
push_ce(out, &rest[span.arg.0..span.arg.1]);
rest = &rest[span.end..];
}
push_run(out, rest);
}
/// Emits a non-chemistry run of a math span, dropping an empty one and keeping a
/// whitespace-only one as the single space it separates two calls with.
fn push_run(out: &mut String, latex: &str) {
if latex.is_empty() {
return;
}
if latex.trim().is_empty() {
out.push(' ');
return;
}
push_mi(out, latex);
}
/// Emits a `#mi(...)` call wrapping LaTeX math.
fn push_mi(out: &mut String, latex: &str) {
out.push_str("#mi(");
push_typst_string(out, latex);
out.push(')');
}
/// Emits a `#ce(...)` call wrapping an mhchem argument.
///
/// The argument is passed through as written. whalogen reads the same formula,
/// charge, bond, and arrow syntax mhchem does, so `H2O`, `<=>`, `[AgCl2]-`, and
/// `->[H2O]` need no translation. Its isotope and oxidation-number spellings do
/// differ (`@Th,227,90@` against mhchem's `^{227}_{90}Th`), and so do `\pu` and
/// `\bond`, which whalogen has no equivalent for. Those print oddly rather than
/// failing the build, so a bank that uses them needs its own pass.
fn push_ce(out: &mut String, argument: &str) {
out.push_str("#ce(");
push_typst_string(out, argument.trim());
out.push(')');
}
/// 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.
@@ -173,7 +485,7 @@ pub fn escape_html(s: &str) -> String {
/// # Returns
///
/// The substituted text.
fn apply_symbols(s: &str, html: bool) -> String {
pub(crate) fn apply_symbols(s: &str, html: bool) -> String {
let mut out = s.to_string();
for (token, entity, plain) in SYMBOLS {
if out.contains(token) {
@@ -192,7 +504,7 @@ fn apply_symbols(s: &str, html: bool) -> String {
/// # Returns
///
/// The text with inline markup converted.
fn apply_inline(s: &str) -> String {
pub(crate) fn apply_inline(s: &str) -> String {
let mut out = s.to_string();
// Bracketed forms first: their contents may contain other markup characters.
out = wrap_bracket(&out, "#sub[", "<sub>", "</sub>");
@@ -379,4 +691,138 @@ mod tests {
assert_eq!(to_typst("a @ b"), "a \\@ b");
assert_eq!(to_typst("x < y"), "x \\< y");
}
#[test]
fn markdown_uses_pandoc_scripts_and_unicode_symbols() {
assert_eq!(to_markdown("H#sub[2]O"), "H~2~O");
assert_eq!(to_markdown("x#sup[2]"), "x^2^");
assert_eq!(
to_markdown("K#sub[m] #sym.approx 5 mM"),
"K~m~ \u{2248} 5 mM"
);
// Bold, italic, and code are already Markdown.
assert_eq!(
to_markdown("**bold** and *em* and `code`"),
"**bold** and *em* and `code`"
);
// Paragraph breaks survive.
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");
}
#[test]
fn mhchem_goes_to_whalogen_rather_than_mitex() {
// The regression: mitex implements no LaTeX packages, so `\ce` reached the
// plugin as an unknown command and failed the whole document rather than
// printing badly.
assert_eq!(to_typst("$\\ce{H2O}$"), "#ce(\"H2O\")");
assert_eq!(
to_typst("water ionizes, $\\ce{H2O <=> H+ + OH-}$, giving"),
"water ionizes, #ce(\"H2O <=> H+ + OH-\"), giving"
);
}
#[test]
fn a_span_can_mix_chemistry_with_ordinary_math() {
// The chemistry leaves the span and the rest of it still reaches mitex, so
// an equation that needs both renders both.
assert_eq!(to_typst("$K_w = \\ce{H2O}$"), "#mi(\"K_w = \")#ce(\"H2O\")");
// The space between the two runs stays inside the `mi` call rather than
// becoming content-mode whitespace, so the spacing is TeX's to decide.
assert_eq!(
to_typst("$\\ce{H2O} \\to \\Delta H$"),
"#ce(\"H2O\")#mi(\" \\\\to \\\\Delta H\")"
);
}
#[test]
fn mhchem_arguments_keep_their_nested_braces() {
// Matched on brace depth, not on the first `}`, or the charge is orphaned
// and the remaining `}` closes the `#ce(` call early.
assert_eq!(to_typst("$\\ce{Fe^{2+}}$"), "#ce(\"Fe^{2+}\")");
assert_eq!(
to_typst("$\\ce{SO4^{2-} + Ba^{2+}}$"),
"#ce(\"SO4^{2-} + Ba^{2+}\")"
);
}
#[test]
fn a_latex_line_break_is_not_read_as_the_chemistry_command() {
// `\\` is an escaped backslash followed by the letters `ce`, not `\ce`.
// Scanning that skips escaped pairs as a unit keeps the two apart; scanning
// that does not would convert a line break into a formula.
assert_eq!(to_typst("$a \\\\ce{x}$"), "#mi(\"a \\\\\\\\ce{x}\")");
}
#[test]
fn commands_that_merely_start_with_ce_are_left_to_mitex() {
// The name has to end at the `e`, or `\cellcolor` is half-consumed and the
// conversion invents a formula out of its argument.
assert_eq!(
to_typst("$\\cellcolor{red} x$"),
"#mi(\"\\\\cellcolor{red} x\")"
);
}
#[test]
fn unbalanced_chemistry_is_left_for_mitex_to_report() {
// Nothing safe to convert. mitex's own error names the file and line, which
// is more useful than a silently truncated formula.
assert_eq!(to_typst("$\\ce{H2O$"), "#mi(\"\\\\ce{H2O\")");
}
#[test]
fn chemistry_outside_math_is_converted_too() {
// A bare `\ce` never reaches mitex at all, and `\c` is an escape in Typst
// content mode, so leaving it alone produces a document that either fails
// or prints the letters.
assert_eq!(
to_typst("the backbone \\ce{-NH} group"),
"the backbone #ce(\"-NH\") group"
);
}
#[test]
fn a_template_without_the_chemistry_import_is_named() {
let body = "#question((stem: [#ce(\"H2O\")]))";
assert!(needs_chem_import(
"#import \"@preview/mitex:0.2.7\": mi",
body
));
// An import of any whalogen version, or a template's own `ce`, is enough.
assert!(!needs_chem_import(CHEM_IMPORT, body));
assert!(!needs_chem_import(
"#import \"@preview/whalogen:0.2.0\": ce as ce",
body
));
assert!(!needs_chem_import("#let ce(f) = f", body));
// No chemistry in the body, nothing to warn about.
assert!(!needs_chem_import(
"#import \"@preview/mitex:0.2.7\": mi",
"#mi(\"x\")"
));
}