Compare commits
17
Commits
nightly
...
9755417899
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9755417899
|
||
|
|
beec860a2c
|
||
|
|
cea03048b8
|
||
|
|
ba84c4d82a
|
||
|
|
92c8a8fc75
|
||
|
|
c06d5caa8c
|
||
|
|
994e9065e8
|
||
|
|
6ae993d393
|
||
|
|
1b6b1f99fd
|
||
|
|
51f095d677
|
||
|
|
b375e6c94f
|
||
|
|
d38d89549c
|
||
|
|
4cd33f1768
|
||
|
|
1cc8137be9
|
||
|
|
327ac371e4
|
||
|
|
f475c630e0
|
||
|
|
468af3a815
|
@@ -1,4 +1,5 @@
|
||||
preview
|
||||
scratch
|
||||
|
||||
/dist/
|
||||
/THIRD-PARTY-LICENSES.txt
|
||||
|
||||
+8
-7
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
|
||||
@@ -35,5 +35,6 @@
|
||||
|
||||
pub mod calibrate;
|
||||
pub mod classical;
|
||||
pub mod diagnostic;
|
||||
pub mod irt;
|
||||
pub mod students;
|
||||
|
||||
+159
-20
@@ -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,7 +1006,9 @@ mod tests {
|
||||
} else {
|
||||
vec![letter.to_string()]
|
||||
},
|
||||
selected_source: vec![],
|
||||
eliminated: vec![],
|
||||
eliminated_source: vec![],
|
||||
correct: Some(credit >= 0.999),
|
||||
credit,
|
||||
points_possible: 1.0,
|
||||
@@ -881,6 +1019,7 @@ mod tests {
|
||||
topics: vec![],
|
||||
bonus: false,
|
||||
dropped: false,
|
||||
dropped_full_credit: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -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 {
|
||||
|
||||
@@ -563,7 +563,7 @@ fn missed_items(
|
||||
if let Some(entry) = cat.get(uid) {
|
||||
// Feedback for the specific option chosen, which is the whole
|
||||
// point of recording per-distractor misconceptions.
|
||||
if let Some(letter) = r.selected.first() {
|
||||
if let Some(letter) = r.chosen().first() {
|
||||
if let Some(choice) = entry.item.option(letter) {
|
||||
misconception = choice.misconception.clone();
|
||||
feedback = choice.student_text().map(|s| s.to_string());
|
||||
@@ -592,7 +592,7 @@ fn missed_items(
|
||||
out.push(MissedItem {
|
||||
number: r.item_number,
|
||||
item_ref: r.item_ref.clone(),
|
||||
selected: r.selected.clone(),
|
||||
selected: r.chosen().to_vec(),
|
||||
credit: r.credit,
|
||||
level: r.level,
|
||||
learning_objectives: r.learning_objectives.clone(),
|
||||
@@ -1089,6 +1089,7 @@ mod tests {
|
||||
assessment_id: "a".into(),
|
||||
date: None,
|
||||
form: None,
|
||||
form_position: None,
|
||||
student_key: student.into(),
|
||||
sid: None,
|
||||
name: None,
|
||||
@@ -1098,7 +1099,9 @@ mod tests {
|
||||
item_ref: None,
|
||||
item_version: None,
|
||||
selected: vec!["A".into()],
|
||||
selected_source: vec![],
|
||||
eliminated: vec![],
|
||||
eliminated_source: vec![],
|
||||
correct: Some(credit >= 0.999),
|
||||
credit,
|
||||
points_possible: 1.0,
|
||||
@@ -1109,6 +1112,7 @@ mod tests {
|
||||
topics: vec![],
|
||||
bonus: false,
|
||||
dropped: false,
|
||||
dropped_full_credit: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
+221
-5
@@ -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,12 @@ fn lecture_schema() -> Value {
|
||||
"date": date("Date delivered."),
|
||||
"unit": { "type": "string", "description": "Unit id." },
|
||||
"slides_url": { "type": "string" },
|
||||
"readings": string_array("Readings assigned with this lecture.")
|
||||
"readings": {
|
||||
"type": "array",
|
||||
"description": "Readings assigned with this lecture, in the order you assign \
|
||||
them. A plain string is the pre-schema form and still loads.",
|
||||
"items": reading_schema()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -306,6 +348,13 @@ fn objective_schema() -> Value {
|
||||
"text": text("The objective as a student would read it. Start with a verb."),
|
||||
"unit": { "type": "string" },
|
||||
"lectures": string_array("Lecture ids that cover this."),
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Position in teaching order, low first. A lecture page numbers \
|
||||
objectives by this; without it they sort by id, which puts \
|
||||
an objective before its own prerequisite."
|
||||
},
|
||||
"level_ceiling": level(),
|
||||
"prerequisites": string_array(
|
||||
"Objective ids that must come first. Cycles are rejected."
|
||||
@@ -320,6 +369,93 @@ fn objective_schema() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema for one cited work.
|
||||
fn reference_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["title"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"label": text("Short form a reading list shows, such as KKW. One work per label."),
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": strings(&[
|
||||
"book", "chapter", "article", "preprint", "thesis",
|
||||
"website", "software", "dataset", "video", "other"
|
||||
]),
|
||||
"description": "Kind of work, following BibTeX entry types."
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": strings(&["required", "supplemental"]),
|
||||
"description": "required for a course text; supplemental for background."
|
||||
},
|
||||
"title": text("Full title."),
|
||||
"authors": string_array("Authors as `Family, Given`, in printed order."),
|
||||
"year": { "type": "integer", "description": "Year of publication." },
|
||||
"edition": { "type": "string", "description": "Edition as printed: 7th." },
|
||||
"publisher": { "type": "string" },
|
||||
"container": { "type": "string", "description": "Journal, edited volume, or series." },
|
||||
"volume": { "type": "string" },
|
||||
"issue": { "type": "string" },
|
||||
"pages": { "type": "string", "description": "Pages of the work, not of a reading." },
|
||||
"doi": { "type": "string", "description": "Bare DOI: 10.1038/nature12373." },
|
||||
"isbn": { "type": "string" },
|
||||
"url": { "type": "string", "description": "Canonical URL for the whole work." },
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"description": "Prefix a reading's `path` is joined to, so the citation key \
|
||||
appears once instead of once per reading."
|
||||
},
|
||||
"note": { "type": "string", "description": "Access notes: reserve shelf, license." }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema for one reading: a location inside a reference, and what it is for.
|
||||
fn reading_schema() -> Value {
|
||||
json!({
|
||||
"oneOf": [
|
||||
{ "type": "string", "description": "The pre-schema form: a citation, unparsed." },
|
||||
reading_mapping_schema()
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/// The mapping form of a reading.
|
||||
fn reading_mapping_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["ref"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"ref": text("Citation key into `references`."),
|
||||
"locator": text("Where inside the work: §6.1, pp. 212-219, ch. 3."),
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Joined to the reference's base_url to reach this location."
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Full URL, when base_url does not cover the location."
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": strings(&["assigned", "supplemental"]),
|
||||
"description": "supplemental means offered but not separately assessed."
|
||||
},
|
||||
"objectives": string_array(
|
||||
"Objective ids this reading serves. A student who misses one of these is \
|
||||
pointed here, so the list is what makes study guidance specific."
|
||||
),
|
||||
"summary": text("What the section contains."),
|
||||
"focus": text("What to take from it. This is the sentence a student report quotes."),
|
||||
"skip": text("What to gloss, and why it is out of scope."),
|
||||
"text": text("A pre-schema citation string, held unparsed.")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The schema for one shared stimulus.
|
||||
fn stimulus_schema() -> Value {
|
||||
json!({
|
||||
@@ -373,6 +509,12 @@ fn course_schema() -> Value {
|
||||
"type": "object",
|
||||
"description": "Shared passages, figures, or data that several items refer to.",
|
||||
"additionalProperties": stimulus_schema()
|
||||
},
|
||||
"references": {
|
||||
"type": "object",
|
||||
"description": "Works the course cites, by citation key. Readings point in \
|
||||
here, so an edition change is one edit.",
|
||||
"additionalProperties": reference_schema()
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -466,6 +608,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 +846,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,8 +876,10 @@ fn item_content_properties() -> Value {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 8,
|
||||
"description": "Absent for an open_response item; at least two for any choice format.",
|
||||
"items": option_schema()
|
||||
},
|
||||
"solution": solution_schema(),
|
||||
"learning_objectives": string_array(
|
||||
"Objective ids this item measures. Reports aggregate on these, so an item with none \
|
||||
contributes to nothing."
|
||||
@@ -702,7 +918,7 @@ fn item_schema() -> Value {
|
||||
}
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["id", "level", "stem", "options"],
|
||||
"required": ["id", "level", "stem"],
|
||||
"additionalProperties": false,
|
||||
"properties": Value::Object(properties)
|
||||
})
|
||||
|
||||
+10
-4
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ pub fn select(
|
||||
let seed = blueprint.seed.unwrap_or(0);
|
||||
let mut notes = Vec::new();
|
||||
|
||||
// ------------------------------------------------------------------ pool
|
||||
// --- pool
|
||||
let eligible: Vec<&crate::catalog::Entry> = catalog
|
||||
.assemblable()
|
||||
.into_iter()
|
||||
@@ -101,7 +101,7 @@ pub fn select(
|
||||
let mut chosen: Vec<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 {
|
||||
@@ -140,7 +140,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 +175,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 {
|
||||
@@ -468,6 +468,7 @@ pub fn to_record(
|
||||
learning_objectives: e.item.learning_objectives.clone(),
|
||||
credit_overrides: BTreeMap::new(),
|
||||
dropped: false,
|
||||
dropped_as: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+219
-4
@@ -23,6 +23,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
use coursebank::assessment::{Kind as AssessmentKind, Platform};
|
||||
use coursebank::catalog::Severity;
|
||||
use coursebank::item::IrtModel;
|
||||
use coursebank::lecture::Style as PageStyle;
|
||||
use coursebank::store;
|
||||
|
||||
/// Manage course item banks, assessments, and the analysis that comes back.
|
||||
@@ -55,6 +56,9 @@ pub(crate) enum Command {
|
||||
Lint(LintArgs),
|
||||
/// Summarize the item pool and objective coverage.
|
||||
Catalog(CatalogArgs),
|
||||
/// Render a lecture's reading list, or check what backs each objective.
|
||||
#[command(subcommand)]
|
||||
Lecture(LectureCommand),
|
||||
/// Work with item banks.
|
||||
#[command(subcommand)]
|
||||
Bank(BankCommand),
|
||||
@@ -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,64 @@ pub(crate) struct LintArgs {
|
||||
}
|
||||
|
||||
/// CLI mirror of [`coursebank::catalog::Severity`].
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum LectureCommand {
|
||||
/// Write the readings block for one lecture.
|
||||
///
|
||||
/// The course file is the source of truth for what a lecture assigns and why,
|
||||
/// so the list on the website is generated from it. Objective numbers are
|
||||
/// positional and are resolved here rather than authored.
|
||||
Readings {
|
||||
/// Lecture id, e.g. L1.1.
|
||||
id: String,
|
||||
/// Which flavour of Markdown to write.
|
||||
#[arg(long, value_enum, default_value = "quarto")]
|
||||
style: StyleArg,
|
||||
/// Output path; prints to stdout when omitted.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Write the objectives block for one lecture, grouped by level.
|
||||
///
|
||||
/// The numbering comes from the same place as the `_(LO 4, 7)_` lists in
|
||||
/// `readings`, so generating one and hand-writing the other is what this
|
||||
/// exists to prevent.
|
||||
Objectives {
|
||||
/// Lecture id, e.g. L1.1.
|
||||
id: String,
|
||||
/// Which flavour of Markdown to write.
|
||||
#[arg(long, value_enum, default_value = "quarto")]
|
||||
style: StyleArg,
|
||||
/// Output path; prints to stdout when omitted.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Show the readings behind each objective, and which objectives have none.
|
||||
Coverage {
|
||||
/// Only this lecture's objectives.
|
||||
#[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,
|
||||
@@ -309,6 +373,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 +419,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 +553,23 @@ impl FormatArg {
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub(crate) enum IngestCommand {
|
||||
/// Read a directory of Gradescope per-question CSV exports.
|
||||
/// Read one directory of Gradescope per-question CSV exports per form.
|
||||
///
|
||||
/// Write each source as `FORM=DIR`. A bare path takes `--form`.
|
||||
Gradescope {
|
||||
/// The directory holding 1.csv .. N.csv.
|
||||
dir: PathBuf,
|
||||
/// The directories, e.g. A=exports/e1-a B=exports/e1-b.
|
||||
#[arg(value_name = "SOURCE", required = true)]
|
||||
sources: Vec<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 +661,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 +726,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::*;
|
||||
@@ -584,4 +799,4 @@ mod tests {
|
||||
fn the_cli_rejects_an_unknown_subcommand() {
|
||||
assert!(Cli::try_parse_from(["coursebank", "frobnicate"]).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
+440
-41
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
-5
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! Rendering lecture pages, and checking what backs each objective.
|
||||
//!
|
||||
//! Both handlers here read the course file and nothing else, so neither needs a
|
||||
//! bank or a single response. That is deliberate: a reading list is useful in week
|
||||
//! one, before any item exists.
|
||||
|
||||
use coursebank::course::CourseFile;
|
||||
use coursebank::error::Result;
|
||||
use coursebank::lecture::{objectives_markdown, readings_markdown};
|
||||
use coursebank::yaml;
|
||||
|
||||
use crate::cli::{Cli, LectureCommand};
|
||||
use crate::commands::Outcome;
|
||||
|
||||
/// `lecture`: render a reading list, or report reading coverage.
|
||||
pub(crate) fn lecture(cli: &Cli, sub: &LectureCommand) -> Result<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 objective.
|
||||
///
|
||||
/// Returns [`Outcome::Findings`] when an assessed objective has no reading, since
|
||||
/// that is the case where a student report can name what was missed but not where
|
||||
/// to go and read about it.
|
||||
fn coverage(course: &CourseFile, lecture: Option<&str>, quiet: bool) -> Result<Outcome> {
|
||||
let ids: Vec<String> = match lecture {
|
||||
Some(l) => course
|
||||
.lecture_objectives(l)
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
None => course.objectives_in_order(),
|
||||
};
|
||||
|
||||
for id in &ids {
|
||||
let readings = course.readings_for_objective(id);
|
||||
println!("{id}");
|
||||
if readings.is_empty() {
|
||||
println!(" (no reading)");
|
||||
continue;
|
||||
}
|
||||
for (lecture_id, reading) in readings {
|
||||
let Some(key) = reading.reference.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let reference = course.reference(key, lecture_id)?;
|
||||
let supplemental = match reading.role {
|
||||
coursebank::course::ReadingRole::Supplemental => " (supplemental)",
|
||||
coursebank::course::ReadingRole::Assigned => "",
|
||||
};
|
||||
println!(
|
||||
" {lecture_id} {}{supplemental}",
|
||||
reading.cite(key, reference)
|
||||
);
|
||||
if let Some(focus) = &reading.focus {
|
||||
println!(
|
||||
" {}",
|
||||
course.expand_objective_refs(focus, |o| { course.objective_text(o) })
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let gaps = course.objectives_without_readings();
|
||||
if gaps.is_empty() {
|
||||
if !quiet {
|
||||
println!("\nevery assessed objective has a reading behind it");
|
||||
}
|
||||
return Ok(Outcome::Ok);
|
||||
}
|
||||
println!(
|
||||
"\n{} assessed objective(s) with no reading, so a student report cannot say \
|
||||
where to go back to:",
|
||||
gaps.len()
|
||||
);
|
||||
for id in gaps {
|
||||
println!(" - {id}");
|
||||
}
|
||||
Ok(Outcome::Findings)
|
||||
}
|
||||
+224
-2
@@ -9,7 +9,9 @@
|
||||
//! checking — [`validate`] for problems that must be fixed and [`lint`] for
|
||||
//! item-writing guidance. [`catalog`] summarizes the pool that results.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use coursebank::assessment::AssessmentFile;
|
||||
use coursebank::bank::BankFile;
|
||||
@@ -44,6 +46,9 @@ reports/
|
||||
*.swp
|
||||
";
|
||||
|
||||
/// Marks the block `init` prepends to a `.gitignore` that was already there.
|
||||
const GITIGNORE_HEADER: &str = "# Added by coursebank init.";
|
||||
|
||||
/// `init`: create a new course directory, refusing to clobber an existing one.
|
||||
pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result<Outcome> {
|
||||
let layout = Layout::new(&cli.course);
|
||||
@@ -70,7 +75,7 @@ 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 \
|
||||
coursebank bank new unit-1 --title \"Unit 1\"\n coursebank validate",
|
||||
@@ -79,6 +84,153 @@ pub(crate) fn init(cli: &Cli, args: &InitArgs) -> Result<Outcome> {
|
||||
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);
|
||||
@@ -271,4 +423,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
@@ -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;
|
||||
|
||||
@@ -325,6 +325,10 @@ pub fn ingest(
|
||||
assessment_id: ctx.assessment_id.clone(),
|
||||
date: ctx.date,
|
||||
form: ctx.form.clone(),
|
||||
// Canvas numbers questions as the record does, and its exports
|
||||
// carry no printed order, so there is no printed position to
|
||||
// record and no letter map to decode against.
|
||||
form_position: None,
|
||||
student_key: student_key.clone(),
|
||||
sid: sid.clone(),
|
||||
name: name.clone(),
|
||||
@@ -334,7 +338,9 @@ pub fn ingest(
|
||||
item_ref,
|
||||
item_version: None,
|
||||
selected,
|
||||
selected_source: Vec::new(),
|
||||
eliminated: Vec::new(),
|
||||
eliminated_source: Vec::new(),
|
||||
correct,
|
||||
credit,
|
||||
points_possible: points,
|
||||
@@ -345,6 +351,7 @@ pub fn ingest(
|
||||
topics: Vec::new(),
|
||||
bonus: false,
|
||||
dropped: false,
|
||||
dropped_full_credit: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,804 @@
|
||||
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! Turning what a student marked into what a student chose.
|
||||
//!
|
||||
//! A grading export speaks in positions and printed letters. Question 14 is the
|
||||
//! fourteenth thing on the page; option C is the third bubble. An item bank speaks
|
||||
//! in ids and its own lettering. When forms shuffle, those two vocabularies
|
||||
//! disagree, and every analysis downstream of the disagreement is wrong in a way
|
||||
//! that looks right:
|
||||
//!
|
||||
//! * Pooled distractor statistics add form A's option C to form B's option C,
|
||||
//! which are different sentences. The resulting table is noise with the shape of
|
||||
//! data.
|
||||
//! * A student report looks up the misconception recorded on option C and shows it
|
||||
//! to a student who chose a different option. The feedback is confident,
|
||||
//! specific, and about the wrong thing.
|
||||
//! * Any `credit_overrides` written in the record's lettering are applied to
|
||||
//! whoever happened to mark that letter on their form.
|
||||
//!
|
||||
//! None of these fail loudly. That is the argument for doing the translation once,
|
||||
//! at ingest, and storing both sides of it.
|
||||
//!
|
||||
//! # What a decoder knows
|
||||
//!
|
||||
//! For one form: which recorded question number sits at each printed position,
|
||||
//! which bank letter each printed letter stands for, and which printed letters are
|
||||
//! keyed. It is built from a [`SealFile`] when one exists, and derived from the
|
||||
//! record and the form seed when one does not. Sealed is better, and not only
|
||||
//! because it is faster: a derived decoder describes the form the bank *would*
|
||||
//! print today, while a sealed one describes the form that was actually printed.
|
||||
//!
|
||||
//! # Catching a swapped directory
|
||||
//!
|
||||
//! Gradescope's point-value row reveals which printed letter earned full credit on
|
||||
//! every question. A decoder knows what that letter should be. Comparing them
|
||||
//! across a whole directory is close to a proof of which form the directory holds:
|
||||
//! agreement is near total for the right form and near chance for the wrong one.
|
||||
//! [`identify_form`] uses that to refuse an ingest that names form A over a
|
||||
//! directory of form B papers, which is otherwise a mistake nobody catches until
|
||||
//! the item statistics look strange three weeks later.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::assessment::{AssessmentFile, Form};
|
||||
use crate::catalog::Catalog;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::gradescope::Question;
|
||||
use crate::responses::ResponseSet;
|
||||
use crate::seal::{SealFile, printed_letter};
|
||||
use crate::select;
|
||||
|
||||
/// Where a decoder's mapping came from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Provenance {
|
||||
/// Read from a seal written before administration. Authoritative.
|
||||
Seal,
|
||||
/// Derived from the assessment record and the form seed, as of now.
|
||||
Derived,
|
||||
}
|
||||
|
||||
impl Provenance {
|
||||
/// A short label for output.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Provenance::Seal => "seal",
|
||||
Provenance::Derived => "derived from the record",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a grading export numbers its questions.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Numbering {
|
||||
/// The export counts printed positions, which is what Gradescope's `N.csv`
|
||||
/// file names mean. Positions are translated to recorded numbers.
|
||||
Printed,
|
||||
/// The export already carries recorded question numbers, so numbers pass
|
||||
/// through untouched.
|
||||
Recorded,
|
||||
}
|
||||
|
||||
/// One question's mapping on one form.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QuestionMap {
|
||||
/// Printed position on the page, counting from 1.
|
||||
pub position: u32,
|
||||
/// The recorded question number, the join key to the record and the store.
|
||||
pub number: u32,
|
||||
/// The item's global id.
|
||||
pub item: String,
|
||||
/// Keyed letters as printed on this form.
|
||||
pub printed_key: Vec<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()
|
||||
}
|
||||
|
||||
/// 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 unmapped_letters: BTreeSet<String> = BTreeSet::new();
|
||||
let mut translated = 0usize;
|
||||
|
||||
for row in &mut set.rows {
|
||||
let belongs = row
|
||||
.form
|
||||
.as_deref()
|
||||
.map(|f| f.eq_ignore_ascii_case(&decoder.form))
|
||||
.unwrap_or(false);
|
||||
if !belongs {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(map) = decoder.lookup(row.item_number, numbering) else {
|
||||
unmapped_positions.insert(row.item_number);
|
||||
continue;
|
||||
};
|
||||
|
||||
row.form_position = Some(map.position);
|
||||
row.item_number = map.number;
|
||||
|
||||
let mut convert = |letters: &[String]| -> Vec<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; they were left \
|
||||
untranslated",
|
||||
decoder.form,
|
||||
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_objectives: 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}");
|
||||
}
|
||||
}
|
||||
@@ -633,6 +633,10 @@ pub fn to_responses(questions: &[Question], ctx: &Context) -> Import {
|
||||
assessment_id: ctx.assessment_id.clone(),
|
||||
date: ctx.date,
|
||||
form: ctx.form.clone(),
|
||||
// The printed position and the bank's lettering are written
|
||||
// later, by `decode::apply`, which is the only place that knows
|
||||
// which form this directory holds.
|
||||
form_position: None,
|
||||
student_key,
|
||||
sid: row.sid.clone(),
|
||||
name: row.name.clone(),
|
||||
@@ -642,7 +646,9 @@ pub fn to_responses(questions: &[Question], ctx: &Context) -> Import {
|
||||
item_ref: None,
|
||||
item_version: None,
|
||||
selected,
|
||||
selected_source: Vec::new(),
|
||||
eliminated,
|
||||
eliminated_source: Vec::new(),
|
||||
correct,
|
||||
credit,
|
||||
points_possible: points,
|
||||
@@ -653,6 +659,7 @@ pub fn to_responses(questions: &[Question], ctx: &Context) -> Import {
|
||||
topics: Vec::new(),
|
||||
bonus,
|
||||
dropped: false,
|
||||
dropped_full_credit: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
+113
-11
@@ -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>,
|
||||
@@ -99,10 +108,26 @@ pub struct Response {
|
||||
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.
|
||||
@@ -393,12 +452,23 @@ impl ResponseSet {
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -590,6 +660,22 @@ pub struct FlatResponse {
|
||||
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 +696,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 +706,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(),
|
||||
@@ -660,6 +750,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 +769,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),
|
||||
@@ -713,6 +811,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,7 +821,9 @@ mod tests {
|
||||
item_ref: None,
|
||||
item_version: None,
|
||||
selected: vec!["A".into()],
|
||||
selected_source: vec![],
|
||||
eliminated: vec![],
|
||||
eliminated_source: vec![],
|
||||
correct: Some(credit >= 0.999),
|
||||
credit,
|
||||
points_possible: 2.0,
|
||||
@@ -733,6 +834,7 @@ mod tests {
|
||||
topics: vec![],
|
||||
bonus: false,
|
||||
dropped: false,
|
||||
dropped_full_credit: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-31
@@ -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,7 +551,9 @@ 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,
|
||||
@@ -590,6 +564,7 @@ mod tests {
|
||||
topics: vec![],
|
||||
bonus: false,
|
||||
dropped: false,
|
||||
dropped_full_credit: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,10 @@ pub fn schema() -> Schema {
|
||||
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),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -120,6 +124,10 @@ fn to_batch(rows: &[FlatResponse]) -> Result<RecordBatch> {
|
||||
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 +245,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")?;
|
||||
@@ -263,6 +290,11 @@ fn from_batch(batch: &RecordBatch, path: &Path) -> Result<Vec<FlatResponse>> {
|
||||
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 +304,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,7 +313,13 @@ 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),
|
||||
@@ -291,6 +330,7 @@ fn from_batch(batch: &RecordBatch, path: &Path) -> Result<Vec<FlatResponse>> {
|
||||
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)
|
||||
@@ -336,6 +376,10 @@ mod tests {
|
||||
topics: "kinetics".into(),
|
||||
bonus: false,
|
||||
dropped: false,
|
||||
dropped_full_credit: false,
|
||||
form_position: 0,
|
||||
selected_source: String::new(),
|
||||
eliminated_source: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
// SPDX-License-Identifier: Prosperity-3.0.0
|
||||
// Copyright Scientific Computing Studio
|
||||
// Source: https://git.scient.ing/education/coursebank
|
||||
|
||||
//! Rendering a lecture's objectives and readings as Markdown.
|
||||
//!
|
||||
//! The course file is the source of truth for what a lecture assigns and why, so
|
||||
//! the reading list on the course website is generated rather than kept in step by
|
||||
//! hand. Two copies of the same prose drift within a term; one copy and a build
|
||||
//! step do not.
|
||||
//!
|
||||
//! [`Style::Quarto`] reproduces the definition-list shape a Quarto page wants,
|
||||
//! with `_(LO 4, 7)_` numbering resolved from [`CourseFile::lecture_objectives`].
|
||||
//! Those numbers are positional and so cannot be authored: inserting an objective
|
||||
//! renumbers everything after it. They are computed here and never stored.
|
||||
//!
|
||||
//! What this module does not do is invent prose. Everything printed comes from
|
||||
//! `summary`, `focus`, and `skip` on the reading, in that order, and a reading with
|
||||
//! none of the three renders as a bare citation.
|
||||
|
||||
use crate::course::{CourseFile, Reading, ReadingRole, Reference};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::taxonomy::Level;
|
||||
|
||||
/// Which flavour of Markdown to emit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Style {
|
||||
/// Pandoc definition lists with `<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,
|
||||
}
|
||||
|
||||
/// Renders the objectives for one lecture, grouped by level.
|
||||
///
|
||||
/// The numbering here and the `_(LO 4, 7)_` lists in [`readings_markdown`] come
|
||||
/// from the same call to [`CourseFile::lecture_objectives`], so they cannot
|
||||
/// disagree. Generating one half of the page and hand-writing the other is how you
|
||||
/// get a note pointing at LO 8 when LO 8 has become LO 9.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `course` - the loaded course file.
|
||||
/// * `lecture` - the lecture id.
|
||||
/// * `style` - which flavour to emit.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The Markdown, ending in a newline. Objectives with no `level_ceiling` are
|
||||
/// grouped last under no heading.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Unresolved`] when the lecture id is not registered.
|
||||
pub fn objectives_markdown(course: &CourseFile, lecture: &str, style: Style) -> Result<String> {
|
||||
course.lecture(lecture, "lecture page")?;
|
||||
let ids = course.lecture_objectives(lecture);
|
||||
|
||||
let mut out = String::from("## Learning objectives\n\n");
|
||||
out.push_str("After this lecture, you should be able to do the following.\n\n");
|
||||
|
||||
// Levels in taxonomy order, then whatever declares no ceiling.
|
||||
let mut groups: Vec<(Option<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.learning_objectives[*id].level_ceiling;
|
||||
if let Some(slot) = groups.iter_mut().find(|(level, _)| *level == ceiling) {
|
||||
slot.1.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (level, members) in &groups {
|
||||
if members.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(level) = level {
|
||||
out.push_str(&format!("### {}\n\n", level.name()));
|
||||
}
|
||||
for id in members {
|
||||
let text = course.objective_text(id);
|
||||
out.push_str(&match style {
|
||||
Style::Quarto => format!("(@) {text}\n"),
|
||||
Style::Plain => format!("1. {text}\n"),
|
||||
});
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Renders the readings for one lecture.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `course` - the loaded course file.
|
||||
/// * `lecture` - the lecture id, such as `L1.1`.
|
||||
/// * `style` - which flavour to emit.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The Markdown, ending in a newline. Supplemental readings follow the assigned
|
||||
/// ones under their own subheading, and are omitted entirely when there are none.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Unresolved`] when the lecture id or a cited reference is not
|
||||
/// registered.
|
||||
pub fn readings_markdown(course: &CourseFile, lecture: &str, style: Style) -> Result<String> {
|
||||
let lec = course.lecture(lecture, "lecture page")?;
|
||||
|
||||
// Positional numbers for this page, so `{lo-id}` in a note and the trailing
|
||||
// `_(LO ...)_` agree with the objective list printed above them.
|
||||
let order = course.lecture_objectives(lecture);
|
||||
let number = |id: &str| order.iter().position(|o| *o == id).map(|i| i + 1);
|
||||
|
||||
let mut out = String::from("## Readings\n\n");
|
||||
for role in [ReadingRole::Assigned, ReadingRole::Supplemental] {
|
||||
let group: Vec<&Reading> = lec.readings.iter().filter(|r| r.role == role).collect();
|
||||
if group.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if role == ReadingRole::Supplemental {
|
||||
out.push_str("### Supplemental\n\n");
|
||||
}
|
||||
for reading in group {
|
||||
out.push_str(&entry(course, reading, style, &number)?);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Renders one reading.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `course` - the course, for resolving references and placeholders.
|
||||
/// * `reading` - the reading.
|
||||
/// * `style` - which flavour to emit.
|
||||
/// * `number` - the position of an objective on this page, if it has one.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The entry, followed by a blank line.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Unresolved`] when the cited reference is not registered.
|
||||
fn entry(
|
||||
course: &CourseFile,
|
||||
reading: &Reading,
|
||||
style: Style,
|
||||
number: &impl Fn(&str) -> Option<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!("LO {n}"),
|
||||
None => course.objective_text(id),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
match style {
|
||||
Style::Quarto => {
|
||||
if !body.is_empty() {
|
||||
out.push_str(&format!(": {}\n", body.join("\n")));
|
||||
}
|
||||
let mut numbers: Vec<usize> = reading
|
||||
.objectives
|
||||
.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_(LO {})_\n", list.join(", ")));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
Style::Plain => {
|
||||
if !body.is_empty() {
|
||||
out.push_str(&format!(" {}\n", body.join(" ")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The citation line that opens an entry.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `reading` - the reading.
|
||||
/// * `key` - its citation key.
|
||||
/// * `reference` - the cited work.
|
||||
/// * `style` - which flavour to emit.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A linked citation when the location has a URL, and a plain one when it does not.
|
||||
fn heading(reading: &Reading, key: &str, reference: &Reference, style: Style) -> String {
|
||||
let label = reference.label.as_deref().unwrap_or(key);
|
||||
let locator = reading.locator.as_deref().unwrap_or("");
|
||||
let linked = match reading.resolve_url(reference) {
|
||||
Some(url) if !locator.is_empty() => format!("[{locator}]({url})"),
|
||||
Some(url) => format!("[{}]({url})", reference.title),
|
||||
None if !locator.is_empty() => locator.to_string(),
|
||||
None => reference.title.clone(),
|
||||
};
|
||||
match style {
|
||||
Style::Quarto => format!("`{label}` {linked}\n"),
|
||||
Style::Plain => format!("- **{label}** {linked}\n"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::course::{Lecture, Objective, ReferenceRole};
|
||||
|
||||
/// A course with one lecture, two objectives, and one reference.
|
||||
fn course() -> CourseFile {
|
||||
let mut c = CourseFile::skeleton("BIOSC 1000", "Biochemistry", "2026f");
|
||||
c.lectures.clear();
|
||||
c.learning_objectives.clear();
|
||||
|
||||
c.references.insert(
|
||||
"kuriyan2013molecules".into(),
|
||||
Reference {
|
||||
label: Some("KKW".into()),
|
||||
role: ReferenceRole::Required,
|
||||
title: "The molecules of life".into(),
|
||||
base_url: Some("https://example.org/kkw/".into()),
|
||||
..Reference::default()
|
||||
},
|
||||
);
|
||||
for (id, order) in [("lo-second", 2), ("lo-first", 1)] {
|
||||
c.learning_objectives.insert(
|
||||
id.into(),
|
||||
Objective {
|
||||
text: format!("objective {id}"),
|
||||
lectures: vec!["L1.1".into()],
|
||||
order: Some(order),
|
||||
..objective_defaults()
|
||||
},
|
||||
);
|
||||
}
|
||||
c.lectures.insert(
|
||||
"L1.1".into(),
|
||||
Lecture {
|
||||
title: "Enthalpy".into(),
|
||||
date: None,
|
||||
unit: None,
|
||||
slides_url: None,
|
||||
readings: vec![
|
||||
Reading {
|
||||
reference: Some("kuriyan2013molecules".into()),
|
||||
locator: Some("§6.1".into()),
|
||||
path: Some("6/A/#1".into()),
|
||||
objectives: vec!["lo-second".into(), "lo-first".into()],
|
||||
summary: Some("What a system is.".into()),
|
||||
focus: Some("A worked instance of {lo-first}.".into()),
|
||||
..Reading::default()
|
||||
},
|
||||
Reading {
|
||||
reference: Some("kuriyan2013molecules".into()),
|
||||
locator: Some("§1.9".into()),
|
||||
path: Some("1/B/#9".into()),
|
||||
role: ReadingRole::Supplemental,
|
||||
objectives: vec!["lo-second".into()],
|
||||
summary: Some("Background.".into()),
|
||||
..Reading::default()
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
c
|
||||
}
|
||||
|
||||
/// The non-defaulted half of an objective, so the fixtures stay short.
|
||||
fn objective_defaults() -> Objective {
|
||||
Objective {
|
||||
text: String::new(),
|
||||
unit: None,
|
||||
lectures: Vec::new(),
|
||||
order: None,
|
||||
level_ceiling: None,
|
||||
prerequisites: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
assessed: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_quarto_form_matches_the_page_it_replaces() {
|
||||
let md = readings_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
|
||||
let expected = "\
|
||||
## Readings
|
||||
|
||||
`KKW` [§6.1](https://example.org/kkw/6/A/#1)
|
||||
: What a system is.
|
||||
A worked instance of LO 1.
|
||||
<br>
|
||||
_(LO 1, 2)_
|
||||
|
||||
### Supplemental
|
||||
|
||||
`KKW` [§1.9](https://example.org/kkw/1/B/#9)
|
||||
: Background.
|
||||
<br>
|
||||
_(LO 2)_
|
||||
|
||||
";
|
||||
assert_eq!(md, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_numbers_follow_teaching_order_not_id_order() {
|
||||
// `lo-second` sorts first alphabetically and second by `order`.
|
||||
let md = readings_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
|
||||
assert!(md.contains("_(LO 1, 2)_"));
|
||||
assert!(md.contains("A worked instance of LO 1."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objectives_group_by_level_in_taxonomy_order() {
|
||||
let mut c = course();
|
||||
c.learning_objectives
|
||||
.get_mut("lo-first")
|
||||
.expect("fixture")
|
||||
.level_ceiling = Some(Level::Remember);
|
||||
c.learning_objectives
|
||||
.get_mut("lo-second")
|
||||
.expect("fixture")
|
||||
.level_ceiling = Some(Level::Apply);
|
||||
let md = objectives_markdown(&c, "L1.1", Style::Quarto).expect("renders");
|
||||
let expected = "\
|
||||
## Learning objectives
|
||||
|
||||
After this lecture, you should be able to do the following.
|
||||
|
||||
### Remember
|
||||
|
||||
(@) objective lo-first
|
||||
|
||||
### Apply
|
||||
|
||||
(@) objective lo-second
|
||||
|
||||
";
|
||||
assert_eq!(md, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_objective_with_no_level_still_appears() {
|
||||
// Ungrouped, at the end, rather than silently dropped.
|
||||
let md = objectives_markdown(&course(), "L1.1", Style::Quarto).expect("renders");
|
||||
assert!(md.contains("(@) objective lo-first"));
|
||||
assert!(md.contains("(@) objective lo-second"));
|
||||
assert!(!md.contains("###"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_supplemental_heading_appears_only_when_something_is_under_it() {
|
||||
let mut c = course();
|
||||
c.lectures.get_mut("L1.1").expect("lecture").readings.pop();
|
||||
let md = readings_markdown(&c, "L1.1", Style::Quarto).expect("renders");
|
||||
assert!(!md.contains("Supplemental"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legacy_string_reading_still_renders() {
|
||||
let mut c = course();
|
||||
let readings = &mut c.lectures.get_mut("L1.1").expect("lecture").readings;
|
||||
readings.clear();
|
||||
readings.push(Reading {
|
||||
text: Some("KKW §6.1: system and surroundings. https://example.org".into()),
|
||||
..Reading::default()
|
||||
});
|
||||
let md = readings_markdown(&c, "L1.1", Style::Quarto).expect("renders");
|
||||
assert!(md.contains("system and surroundings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_reference_is_an_error_rather_than_a_blank() {
|
||||
let mut c = course();
|
||||
c.references.clear();
|
||||
let err = readings_markdown(&c, "L1.1", Style::Quarto);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,675 @@
|
||||
// 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.dropped).enumerate() {
|
||||
let entry = catalog.require(&placement.item)?;
|
||||
let item = &entry.item;
|
||||
let number = position + 1;
|
||||
|
||||
if let Some(stimulus_id) = &item.stimulus {
|
||||
if printed_stimulus.as_deref() != Some(stimulus_id.as_str()) {
|
||||
if let Some(stimulus) = course.stimuli.get(stimulus_id) {
|
||||
out.push_str("::: {.stimulus}\n\n");
|
||||
out.push_str(&markup::to_markdown(&stimulus.body));
|
||||
out.push_str("\n\n:::\n\n");
|
||||
}
|
||||
printed_stimulus = Some(stimulus_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
match opts.variant {
|
||||
Variant::Worksheet => worksheet_question(
|
||||
&mut out,
|
||||
number,
|
||||
placement,
|
||||
item,
|
||||
&opts.form,
|
||||
opts.answer_space,
|
||||
),
|
||||
Variant::Solutions => {
|
||||
solution_question(&mut out, number, placement, item, &opts.form, course)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The Quarto YAML front matter.
|
||||
fn front_matter(course: &CourseFile, record: &AssessmentFile, variant: Variant) -> String {
|
||||
let title = if matches!(variant, Variant::Solutions) {
|
||||
format!("{}: {}", record.assessment.title, variant.title_word())
|
||||
} else {
|
||||
record.assessment.title.clone()
|
||||
};
|
||||
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_objectives.is_empty() {
|
||||
return;
|
||||
}
|
||||
let texts: Vec<String> = item
|
||||
.learning_objectives
|
||||
.iter()
|
||||
.map(|id| course.objective_text(id))
|
||||
.collect();
|
||||
out.push_str(&format!("**Tests:** {}\n\n", texts.join("; ")));
|
||||
}
|
||||
|
||||
/// The `Review:` line, resolving each citation to a short label, linked when a URL
|
||||
/// resolves.
|
||||
fn review_line(out: &mut String, item: &Item, course: &CourseFile) {
|
||||
let Some(solution) = item.solution.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if solution.review.is_empty() {
|
||||
return;
|
||||
}
|
||||
let cites: Vec<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_objectives: [lo-enthalpy]
|
||||
options:
|
||||
- { id: A, text: "the enthalpy change", correct: true, feedback_student: "Right: P dV work is folded into H." }
|
||||
- { id: B, text: "the internal energy change", misconception: "ignores expansion work" }
|
||||
- { id: C, text: "zero" }
|
||||
solution:
|
||||
explanation: "Because H = U + PV, at constant P the P dV term is the expansion work, so q_p equals the change in H."
|
||||
review:
|
||||
- { ref: kkw, locator: "§6.4", path: "6/A/#4" }
|
||||
- id: q-enthalpy-op-001
|
||||
status: draft
|
||||
level: 2
|
||||
format: open_response
|
||||
stem: Explain why, at constant pressure, the heat exchanged equals the enthalpy change.
|
||||
learning_objectives: [lo-enthalpy]
|
||||
solution:
|
||||
model_answer: "At constant pressure the P dV expansion work is folded into H = U + PV, so q_p is the change in H."
|
||||
rubric:
|
||||
- { description: "states H = U + PV", points: 1 }
|
||||
- { description: "identifies q_p with the enthalpy change", points: 1 }
|
||||
review:
|
||||
- { ref: kkw, locator: "§6.4", path: "6/A/#4" }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
Catalog::load(&dir).expect("catalog loads")
|
||||
}
|
||||
|
||||
fn record() -> AssessmentFile {
|
||||
AssessmentFile {
|
||||
schema_version: "1.0".into(),
|
||||
assessment: Assessment {
|
||||
id: "hw-1".into(),
|
||||
title: "Homework 1".into(),
|
||||
term: None,
|
||||
kind: Kind::Homework,
|
||||
date: None,
|
||||
platform: Platform::Canvas,
|
||||
minutes_allowed: None,
|
||||
attempts: None,
|
||||
shuffle: None,
|
||||
scoring_policy: None,
|
||||
instructions: None,
|
||||
notes: None,
|
||||
},
|
||||
blueprint: None,
|
||||
forms: Vec::new(),
|
||||
items: vec![
|
||||
Placement {
|
||||
number: 1,
|
||||
item: "l11::q-enthalpy-001".into(),
|
||||
version: None,
|
||||
fingerprint: None,
|
||||
points: Some(1.0),
|
||||
bonus: false,
|
||||
key: vec!["A".into()],
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: false,
|
||||
dropped_as: None,
|
||||
},
|
||||
Placement {
|
||||
number: 2,
|
||||
item: "l11::q-enthalpy-op-001".into(),
|
||||
version: None,
|
||||
fingerprint: None,
|
||||
points: Some(2.0),
|
||||
bonus: false,
|
||||
key: Vec::new(),
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: false,
|
||||
dropped_as: None,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worksheet_withholds_the_answer() {
|
||||
let md =
|
||||
worksheet(&catalog("worksheet"), &record(), &Options::default().form).expect("renders");
|
||||
assert!(md.contains("## Question 1"));
|
||||
assert!(md.contains("A. the enthalpy change"));
|
||||
// Nothing that reveals the key or the reasoning.
|
||||
assert!(!md.contains('✓'), "no check marks on the worksheet:\n{md}");
|
||||
assert!(!md.contains("Model answer"), "no model answer:\n{md}");
|
||||
assert!(!md.contains("P dV"), "no explanation:\n{md}");
|
||||
assert!(!md.contains("Rubric"));
|
||||
// The open-response question leaves room to write.
|
||||
assert!(md.contains("answer-space"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solutions_show_key_reasoning_rubric_and_review() {
|
||||
let md =
|
||||
solutions(&catalog("solutions"), &record(), &Options::default().form).expect("renders");
|
||||
assert!(md.contains("A. the enthalpy change ✓"));
|
||||
assert!(md.contains("the internal energy change: ignores expansion work"));
|
||||
assert!(md.contains("**Model answer.**"));
|
||||
assert!(md.contains("H = U + PV"));
|
||||
assert!(md.contains("**Rubric**"));
|
||||
assert!(md.contains("states H = U + PV (1 pt)"));
|
||||
assert!(md.contains("Tests:** Define enthalpy"));
|
||||
// The review citation resolves to the label and a link.
|
||||
assert!(
|
||||
md.contains("`KKW` [§6.4](https://example.org/kkw/6/A/#4)"),
|
||||
"{md}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_matter_titles_each_document() {
|
||||
let ws = worksheet(
|
||||
&catalog("front-matter-ws"),
|
||||
&record(),
|
||||
&Options::default().form,
|
||||
)
|
||||
.expect("renders");
|
||||
assert!(ws.contains("title: \"Homework 1: Questions\""));
|
||||
let sol = solutions(
|
||||
&catalog("front-matter-sol"),
|
||||
&record(),
|
||||
&Options::default().form,
|
||||
)
|
||||
.expect("renders");
|
||||
assert!(sol.contains("title: \"Homework 1: Solutions\""));
|
||||
}
|
||||
}
|
||||
+802
-47
File diff suppressed because it is too large
Load Diff
+11
-11
@@ -98,7 +98,7 @@ pub fn student(
|
||||
));
|
||||
out.push_str(&format!("**{}**\n\n", summary.display_name()));
|
||||
|
||||
// ---------------------------------------------------------------- score
|
||||
// --- score
|
||||
out.push_str(&format!(
|
||||
"You scored **{:.1} of {:.1} points ({:.0}%)**",
|
||||
summary.points, summary.points_possible, summary.percent
|
||||
@@ -144,7 +144,7 @@ pub fn student(
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- objectives
|
||||
// --- objectives
|
||||
if opts.objectives && !summary.objectives.is_empty() {
|
||||
out.push_str("## What this exam says about each learning objective\n\n");
|
||||
out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n");
|
||||
@@ -180,7 +180,7 @@ pub fn student(
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- levels
|
||||
// --- levels
|
||||
if opts.levels && summary.levels.len() > 1 {
|
||||
out.push_str("## Kinds of thinking\n\n");
|
||||
out.push_str(
|
||||
@@ -231,7 +231,7 @@ pub fn student(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- what to do
|
||||
// --- what to do
|
||||
if !summary.focus.is_empty() {
|
||||
out.push_str("## Where to put your time\n\n");
|
||||
out.push_str("In this order:\n\n");
|
||||
@@ -274,7 +274,7 @@ pub fn student(
|
||||
out.push_str(&format!("You have clearly got {}.\n\n", list(&refs)));
|
||||
}
|
||||
|
||||
// --------------------------------------------------------- missed items
|
||||
// --- missed items
|
||||
if opts.missed && !summary.missed.is_empty() {
|
||||
out.push_str("## Question by question\n\n");
|
||||
out.push_str(
|
||||
@@ -356,7 +356,7 @@ pub fn cohort(
|
||||
.unwrap_or_else(|| "date not recorded".into())
|
||||
));
|
||||
|
||||
// ------------------------------------------------------------- summary
|
||||
// --- summary
|
||||
let r = &analysis.reliability;
|
||||
out.push_str("## Summary\n\n");
|
||||
out.push_str(&format!(
|
||||
@@ -408,7 +408,7 @@ pub fn cohort(
|
||||
out.push_str(&format!("> {w}\n\n"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- revise queue
|
||||
// --- revise queue
|
||||
let queue = analysis.revise_queue();
|
||||
out.push_str("## What to revise\n\n");
|
||||
if queue.is_empty() {
|
||||
@@ -477,7 +477,7 @@ pub fn cohort(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- item table
|
||||
// --- item table
|
||||
out.push_str("## Every item\n\n");
|
||||
out.push_str(
|
||||
"| Q | Item | Lv | p | r | D | Blank | Flags |\n|--:|:--|--:|--:|--:|--:|--:|:--|\n",
|
||||
@@ -535,7 +535,7 @@ pub fn cohort(
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- class gaps
|
||||
// --- class gaps
|
||||
out.push_str("## Objectives the class did not meet\n\n");
|
||||
if cohort.class_gaps.is_empty() {
|
||||
out.push_str("Every assessed objective cleared the mastery threshold.\n\n");
|
||||
@@ -556,7 +556,7 @@ pub fn cohort(
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ level coverage
|
||||
// --- level coverage
|
||||
out.push_str("## Coverage and class performance by level\n\n");
|
||||
let counts = record.level_counts();
|
||||
out.push_str("| Level | Items | Class rate |\n|:--|--:|--:|\n");
|
||||
@@ -587,7 +587,7 @@ pub fn cohort(
|
||||
let _ = bp;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- archetypes
|
||||
// --- archetypes
|
||||
if !cohort.archetypes.is_empty() {
|
||||
out.push_str("## Patterns across students\n\n");
|
||||
out.push_str(
|
||||
|
||||
+1030
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -426,6 +427,7 @@ mod tests {
|
||||
learning_objectives: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: true,
|
||||
dropped_as: None,
|
||||
},
|
||||
Placement {
|
||||
number: 2,
|
||||
@@ -439,6 +441,7 @@ mod tests {
|
||||
learning_objectives: Vec::new(),
|
||||
credit_overrides: Default::default(),
|
||||
dropped: false,
|
||||
dropped_as: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+124
-2
@@ -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`.
|
||||
@@ -747,4 +869,4 @@ mod tests {
|
||||
assert_eq!(file.resolve(Variant::Exam).reveal, Reveal::Nothing);
|
||||
assert_eq!(file.resolve(Variant::Key).reveal, Reveal::Everything);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -662,11 +662,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.
|
||||
@@ -1074,8 +1070,61 @@ 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 {
|
||||
/// Rewrites inline LaTeX math (`$...$`) into a call to mitex's `mi`, so Typst's
|
||||
/// own math grammar never has to parse it. Typst's `\times`, `\Delta`, `\ln` and
|
||||
/// friends are not valid Typst math — a bare backslash escapes the next
|
||||
/// character instead of naming a symbol — which is why equations compile but
|
||||
/// print wrong instead of failing outright. `\$` is left alone, matching LaTeX's
|
||||
/// own convention for a literal dollar sign, and a `$` with no matching close is
|
||||
/// left alone too, rather than swallowing the rest of the field.
|
||||
fn rewrite_latex_math(source: &str) -> String {
|
||||
let chars: Vec<(usize, char)> = source.char_indices().collect();
|
||||
let mut out = String::with_capacity(source.len());
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let (_, c) = chars[i];
|
||||
if c == '\\' && i + 1 < chars.len() {
|
||||
out.push('\\');
|
||||
out.push(chars[i + 1].1);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if c != '$' {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut j = i + 1;
|
||||
let close = loop {
|
||||
if j >= chars.len() {
|
||||
break None;
|
||||
}
|
||||
match chars[j].1 {
|
||||
'\\' => j += 2,
|
||||
'$' => break Some(j),
|
||||
_ => j += 1,
|
||||
}
|
||||
};
|
||||
match close {
|
||||
Some(close_idx) => {
|
||||
let start = chars[i + 1].0;
|
||||
let end = chars[close_idx].0;
|
||||
out.push_str("#mi(");
|
||||
out.push_str(&Value::str(&source[start..end]).to_typst(0));
|
||||
out.push(')');
|
||||
i = close_idx + 1;
|
||||
}
|
||||
None => {
|
||||
out.push('$');
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn markup_value(source: &str, content: bool) -> Value {
|
||||
let source = rewrite_latex_math(source);
|
||||
if content {
|
||||
Value::content(source)
|
||||
} else {
|
||||
@@ -1225,3 +1274,17 @@ mod tests {
|
||||
assert!(!balanced("closing ] first"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latex_math_becomes_a_mitex_call() {
|
||||
assert_eq!(
|
||||
rewrite_latex_math("angle $\\phi$ (phi)"),
|
||||
"angle #mi(\"\\\\phi\") (phi)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_and_unmatched_dollar_signs_are_left_alone() {
|
||||
assert_eq!(rewrite_latex_math("costs \\$5 total"), "costs \\$5 total");
|
||||
assert_eq!(rewrite_latex_math("just $5"), "just $5");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,960 @@
|
||||
// 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
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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,
|
||||
objectives: ("lo-sample-met",),
|
||||
objective-texts: (),
|
||||
correct: true,
|
||||
credit: 1.0,
|
||||
bonus: false,
|
||||
dropped: false,
|
||||
blank: false,
|
||||
class-rate: 0.91,
|
||||
taught-in: (),
|
||||
review: (),
|
||||
),
|
||||
(
|
||||
number: 2,
|
||||
level: 3,
|
||||
objectives: ("lo-sample-gap",),
|
||||
objective-texts: ([A sample objective to work on.],),
|
||||
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",
|
||||
objectives-missed: 2,
|
||||
questions-missed: 3,
|
||||
questions: (2, 14, 15),
|
||||
slides: (12, 13),
|
||||
objectives: ([A sample objective 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 shows something the course asked you to do, just as it appears in the syllabus. *Q* tells you how many questions measured that skill. *You* shows the share you got right, and *class* shows the same for everyone else.
|
||||
|
||||
The symbol in the first column is a summary. The mark you should focus on is #text(fill: thin-color, weight: "bold")[?], which means there were not enough questions to draw any conclusions about that line. On exams with many objectives, most lines will have this mark, since one question cannot show if you really know something or just guessed. These lines are not good or bad news. Instead, look for groups of lines that point in the same direction, and check the next two sections, which organize them for you.
|
||||
]
|
||||
|
||||
// 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("objectives-missed", default: 0)
|
||||
let numbers = lecture.at("questions", default: ())
|
||||
let slides = lecture.at("slides", default: ())
|
||||
let objectives = lecture.at("objectives", 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(" · ")])
|
||||
}
|
||||
|
||||
// A real list rather than a middot glued to the front of a paragraph, so the
|
||||
// second line of a long objective indents under the first instead of running
|
||||
// back to the margin.
|
||||
if objectives.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))[·],
|
||||
..objectives.map(objective => markup(objective)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
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, "objective", "objectives")
|
||||
],
|
||||
)
|
||||
]
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#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. If you sort the lectures by how many different objectives were missed, you get a clear order to review, starting with the most challenging. When one lecture covers several objectives, it often means an early idea was unclear, and fixing that is usually the easiest way to help.
|
||||
]
|
||||
|
||||
#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 = ()
|
||||
|
||||
let texts = q.at("objective-texts", default: ())
|
||||
if texts.len() > 0 {
|
||||
parts.push(text(fill: luma(21.57%))[
|
||||
#text(weight: "bold")[Measuring:] #texts.map(t => markup(t)).join([; ])
|
||||
])
|
||||
}
|
||||
|
||||
// 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
@@ -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
@@ -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};
|
||||
|
||||
@@ -31,4 +31,5 @@ pub mod course;
|
||||
pub mod history;
|
||||
pub mod item;
|
||||
pub mod layout;
|
||||
pub mod seal;
|
||||
pub mod taxonomy;
|
||||
|
||||
@@ -281,6 +281,48 @@ 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>,
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
impl AssessmentFile {
|
||||
@@ -451,6 +493,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();
|
||||
|
||||
+96
-11
@@ -352,10 +352,20 @@ fn validate_item(
|
||||
issues.push("version must be at least 1".into());
|
||||
}
|
||||
|
||||
// --- options -----------------------------------------------------------
|
||||
if it.options.len() < 2 {
|
||||
// --- options ----
|
||||
// An open-response item takes no options; its answer lives in `solution`.
|
||||
// Every other format needs at least two things to choose between.
|
||||
if it.format.has_options() {
|
||||
if it.options.len() < 2 {
|
||||
issues.push(format!(
|
||||
"needs at least 2 options, has {}",
|
||||
it.options.len()
|
||||
));
|
||||
}
|
||||
} else if !it.options.is_empty() {
|
||||
issues.push(format!(
|
||||
"needs at least 2 options, has {}",
|
||||
"{} items take no options, but {} were given; put the answer in `solution`",
|
||||
it.format.as_str(),
|
||||
it.options.len()
|
||||
));
|
||||
}
|
||||
@@ -418,7 +428,7 @@ fn validate_item(
|
||||
}
|
||||
}
|
||||
|
||||
// --- key ---------------------------------------------------------------
|
||||
// --- key ---
|
||||
let keys = it.key_indices();
|
||||
match it.format {
|
||||
Format::SingleBestAnswer => {
|
||||
@@ -448,9 +458,14 @@ fn validate_item(
|
||||
issues.push("true_false needs exactly one keyed option".into());
|
||||
}
|
||||
}
|
||||
Format::OpenResponse => {
|
||||
if !keys.is_empty() {
|
||||
issues.push("open_response items have no keyed option".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- level and process must agree -------------------------------------
|
||||
// --- level and process must agree -------
|
||||
if let Some(p) = it.cognitive_process {
|
||||
if !it.level.allows(p) {
|
||||
issues.push(format!(
|
||||
@@ -461,7 +476,7 @@ fn validate_item(
|
||||
}
|
||||
}
|
||||
|
||||
// --- design plausibility ----------------------------------------------
|
||||
// --- design plausibility ------
|
||||
if let Some(d) = &it.design {
|
||||
if let Some(x) = d.expected_difficulty {
|
||||
if !(0.0..=1.0).contains(&x) {
|
||||
@@ -479,7 +494,7 @@ fn validate_item(
|
||||
}
|
||||
}
|
||||
|
||||
// --- calibration plausibility -----------------------------------------
|
||||
// --- calibration plausibility ------
|
||||
if let Some(c) = &it.calibration {
|
||||
if let Some(p) = c.p_value {
|
||||
if !(0.0..=1.0).contains(&p) {
|
||||
@@ -514,7 +529,7 @@ fn validate_item(
|
||||
}
|
||||
}
|
||||
|
||||
// --- history must be coherent -----------------------------------------
|
||||
// --- history must be coherent ------
|
||||
let mut last_version = 0u32;
|
||||
for (i, h) in it.history.iter().enumerate() {
|
||||
if h.version <= last_version {
|
||||
@@ -533,7 +548,7 @@ fn validate_item(
|
||||
));
|
||||
}
|
||||
|
||||
// --- retirement -------------------------------------------------------
|
||||
// --- retirement -----
|
||||
if it.retired.is_some() && it.status != Status::Retired {
|
||||
issues.push(format!(
|
||||
"has a `retired` block but status is `{}`",
|
||||
@@ -541,7 +556,7 @@ fn validate_item(
|
||||
));
|
||||
}
|
||||
|
||||
// --- approval gate ----------------------------------------------------
|
||||
// --- approval gate -------
|
||||
// Approval is what permits an item onto a graded assessment, so it is the
|
||||
// right place to require that the item is fully sourced and designed.
|
||||
if it.status == Status::Approved {
|
||||
@@ -557,9 +572,23 @@ fn validate_item(
|
||||
if it.design.is_none() {
|
||||
issues.push("approved items must carry a design block".into());
|
||||
}
|
||||
// An open-response item is graded from its solution, so approving one with
|
||||
// neither a model answer nor a rubric would leave nothing to mark it by.
|
||||
if !it.format.has_options() {
|
||||
let gradeable = it
|
||||
.solution
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.model_answer.is_some() || !s.rubric.is_empty());
|
||||
if !gradeable {
|
||||
issues.push(
|
||||
"approved open_response items need a solution with a model_answer or a rubric"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- cross-file references --------------------------------------------
|
||||
// --- cross-file references ----
|
||||
if let Some(c) = course {
|
||||
for lo in &it.learning_objectives {
|
||||
match c.learning_objectives.get(lo) {
|
||||
@@ -592,6 +621,15 @@ fn validate_item(
|
||||
issues.push(format!("unknown stimulus `{st}`"));
|
||||
}
|
||||
}
|
||||
// A citation that names a reference key must name a real one, so a review
|
||||
// pointer in the solutions document never resolves to nothing.
|
||||
for citation in it.solution.iter().flat_map(|s| &s.review) {
|
||||
if let Some(key) = &citation.reference {
|
||||
if !c.references.contains_key(key) {
|
||||
issues.push(format!("solution.review cites unknown reference `{key}`"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(floor) = c.policy.partial_credit_floor_level {
|
||||
for o in &it.options {
|
||||
if o.is_partial() && it.level < floor {
|
||||
@@ -644,6 +682,53 @@ mod tests {
|
||||
assert!(b.validate(None).is_empty(), "{:?}", b.validate(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_response_validates_without_options_and_rejects_them() {
|
||||
// No options is fine, and no key is required.
|
||||
let ok = bank(
|
||||
r#"
|
||||
- id: q-a-op-001
|
||||
status: draft
|
||||
level: 2
|
||||
format: open_response
|
||||
stem: Explain the first law.
|
||||
solution:
|
||||
model_answer: Energy is conserved.
|
||||
"#,
|
||||
);
|
||||
assert!(ok.validate(None).is_empty(), "{:?}", ok.validate(None));
|
||||
|
||||
// Giving an open-response item options is the mistake, and so is approving
|
||||
// one with nothing to grade it by.
|
||||
let bad = bank(
|
||||
r#"
|
||||
- id: q-a-op-002
|
||||
status: approved
|
||||
level: 2
|
||||
format: open_response
|
||||
cognitive_process: explain
|
||||
learning_objectives: [lo-x]
|
||||
sources: [{ lecture: L1.1 }]
|
||||
design: { rationale: r }
|
||||
stem: Explain the first law.
|
||||
options:
|
||||
- { id: A, text: a, correct: true }
|
||||
- { id: B, text: b }
|
||||
"#,
|
||||
);
|
||||
let issues = bad.validate(None);
|
||||
assert!(
|
||||
issues.iter().any(|i| i.contains("take no options")),
|
||||
"{issues:?}"
|
||||
);
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|i| i.contains("model_answer or a rubric")),
|
||||
"{issues:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catches_missing_and_multiple_keys() {
|
||||
let b = bank(
|
||||
|
||||
+895
-5
@@ -16,9 +16,12 @@
|
||||
//! belongs to the course and the administration, never to the item.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::de::{self, MapAccess, Visitor};
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::date::Date;
|
||||
use crate::error::{Error, Result};
|
||||
@@ -61,6 +64,12 @@ pub struct CourseFile {
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub learning_objectives: BTreeMap<String, Objective>,
|
||||
|
||||
/// Works the course cites, keyed by citation key such as
|
||||
/// `kuriyan2013molecules`. Readings point in here rather than restating a
|
||||
/// citation, so a reference is written once and a changed edition is one edit.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub references: BTreeMap<String, Reference>,
|
||||
|
||||
/// Shared stimuli for case-based testlets, keyed by id.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub stimuli: BTreeMap<String, Stimulus>,
|
||||
@@ -128,6 +137,58 @@ pub struct Policy {
|
||||
/// The fewest items on an objective before a report will call it mastered.
|
||||
#[serde(default = "two_usize")]
|
||||
pub min_items_for_mastery: usize,
|
||||
/// The letter-grade bands, highest first or in any order.
|
||||
///
|
||||
/// Empty by default, because a grading scale belongs to a course rather than
|
||||
/// to a tool. When it is set, a class report bins the score distribution by
|
||||
/// letter instead of by ten-point interval, which is the only binning a
|
||||
/// student or an instructor actually acts on.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub grade_scale: Vec<GradeBand>,
|
||||
}
|
||||
|
||||
/// One letter-grade band.
|
||||
///
|
||||
/// Only the lower bound is recorded. An upper bound would be a second copy of
|
||||
/// the next band's lower bound, and the two would eventually disagree: a scale
|
||||
/// written as `93.0 - 96.9` leaves 96.95 in no band at all. Bands are read as
|
||||
/// "this letter or better from here up", so the top band needs no ceiling.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GradeBand {
|
||||
/// The letter as it appears on a transcript.
|
||||
pub letter: String,
|
||||
/// The lowest percentage that earns it, inclusive.
|
||||
pub min: f64,
|
||||
/// The grade points it carries, when the course records them.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gpa: Option<f64>,
|
||||
/// The attainment word attached to the band, such as `Meritorious`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub attainment: Option<String>,
|
||||
/// A colour group, so a report can tint A bands alike without parsing
|
||||
/// letters. Defaults to the letter's first character.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<String>,
|
||||
}
|
||||
|
||||
impl GradeBand {
|
||||
/// The group a band belongs to: its own `group`, else its first character.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// An uppercase group key such as `A`.
|
||||
pub fn group_key(&self) -> String {
|
||||
match &self.group {
|
||||
Some(group) => group.to_ascii_uppercase(),
|
||||
None => self
|
||||
.letter
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.to_ascii_uppercase().to_string())
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Policy {
|
||||
@@ -140,10 +201,44 @@ impl Default for Policy {
|
||||
partial_credit_floor_level: None,
|
||||
mastery_threshold: mastery_default(),
|
||||
min_items_for_mastery: 2,
|
||||
grade_scale: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Policy {
|
||||
/// The grade bands, highest lower bound first.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The bands in descending order, empty when the course sets no scale.
|
||||
pub fn bands(&self) -> Vec<&GradeBand> {
|
||||
let mut out: Vec<&GradeBand> = self.grade_scale.iter().collect();
|
||||
out.sort_by(|a, b| {
|
||||
b.min
|
||||
.partial_cmp(&a.min)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The band a percentage falls in.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `percent` - a score out of 100.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The band, or `None` when the course sets no scale or the score sits below
|
||||
/// every band in it.
|
||||
pub fn band_for(&self, percent: f64) -> Option<&GradeBand> {
|
||||
self.bands()
|
||||
.into_iter()
|
||||
.find(|band| percent + 1e-9 >= band.min)
|
||||
}
|
||||
}
|
||||
|
||||
/// A unit or module of the course.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -172,9 +267,318 @@ pub struct Lecture {
|
||||
/// Where the slides live, for study guidance in student reports.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slides_url: Option<String>,
|
||||
/// Assigned readings for the session.
|
||||
/// Assigned readings for the session, in the order you assign them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub readings: Vec<String>,
|
||||
pub readings: Vec<Reading>,
|
||||
}
|
||||
|
||||
/// A work the course cites: a textbook, an article, a dataset, a recording.
|
||||
///
|
||||
/// Keyed by citation key, so this registry is a bibliography rather than a second
|
||||
/// naming scheme. The field names follow BibTeX where BibTeX has one, which makes
|
||||
/// import and export mechanical.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Reference {
|
||||
/// The short form a reading list shows, such as `KKW`. Unique across the
|
||||
/// registry, because reports print it in place of the key.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
/// What kind of work this is, which decides how a citation renders.
|
||||
#[serde(default)]
|
||||
pub kind: ReferenceKind,
|
||||
/// Whether the course requires it or lists it as background.
|
||||
#[serde(default)]
|
||||
pub role: ReferenceRole,
|
||||
/// Full title.
|
||||
pub title: String,
|
||||
/// Authors as `Family, Given`, in the order printed on the work.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub authors: Vec<String>,
|
||||
/// Year of publication.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub year: Option<u32>,
|
||||
/// Edition as printed: `7th`, `Revised`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub edition: Option<String>,
|
||||
/// Publisher, for a book.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub publisher: Option<String>,
|
||||
/// The journal, edited volume, or series this sits inside.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub container: Option<String>,
|
||||
/// Volume within the container.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub volume: Option<String>,
|
||||
/// Issue within the volume.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub issue: Option<String>,
|
||||
/// Page range of the work as a whole, not of any one reading.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pages: Option<String>,
|
||||
/// DOI, bare: `10.1038/nature12373`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub doi: Option<String>,
|
||||
/// ISBN, for a book.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub isbn: Option<String>,
|
||||
/// Canonical URL for the work as a whole.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
/// Prefix a reading's `path` is appended to. Having this means the citation
|
||||
/// key appears once rather than once per reading.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
/// Anything students need to know about getting hold of it: reserve shelf,
|
||||
/// license, paywall.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// The kind of work, chosen to map onto BibTeX entry types.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ReferenceKind {
|
||||
/// A whole book.
|
||||
#[default]
|
||||
Book,
|
||||
/// A chapter in an edited volume.
|
||||
Chapter,
|
||||
/// A journal article.
|
||||
Article,
|
||||
/// A preprint, which is an article without a container.
|
||||
Preprint,
|
||||
/// A thesis or dissertation.
|
||||
Thesis,
|
||||
/// A page or resource that exists only online.
|
||||
Website,
|
||||
/// A program or library.
|
||||
Software,
|
||||
/// A published dataset.
|
||||
Dataset,
|
||||
/// A recording.
|
||||
Video,
|
||||
/// Anything else.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// Whether the course requires a work or offers it as background.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ReferenceRole {
|
||||
/// A course text. Assigned readings come from it.
|
||||
Required,
|
||||
/// Listed so students know it exists. Never assigned.
|
||||
#[default]
|
||||
Supplemental,
|
||||
}
|
||||
|
||||
/// One assigned location inside a [`Reference`], and what it is assigned for.
|
||||
///
|
||||
/// The prose splits three ways because each part answers a different question and
|
||||
/// each has a different consumer. `summary` says what the section contains, `focus`
|
||||
/// says what to take from it, and `skip` says what to ignore. A student report
|
||||
/// quotes `focus` at somebody who missed the objective; a lecture page prints all
|
||||
/// three.
|
||||
///
|
||||
/// A reading written as a bare string, which is what this field held before the
|
||||
/// schema existed, still parses: the whole string lands in `text`, and serializing
|
||||
/// writes it back out as a string rather than a mapping.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Reading {
|
||||
/// Citation key into [`CourseFile::references`].
|
||||
pub reference: Option<String>,
|
||||
/// Where inside the work: `§6.1`, `pp. 212-219`, `ch. 3`, `fig. 4`.
|
||||
pub locator: Option<String>,
|
||||
/// Appended to the reference's `base_url` to reach this location.
|
||||
pub path: Option<String>,
|
||||
/// A full URL, for a location that is not under the reference's `base_url`.
|
||||
pub url: Option<String>,
|
||||
/// Whether it is assigned or offered alongside.
|
||||
pub role: ReadingRole,
|
||||
/// The objectives this reading serves.
|
||||
pub objectives: Vec<String>,
|
||||
/// What the section contains.
|
||||
pub summary: Option<String>,
|
||||
/// What to take from it, which is the sentence a study suggestion quotes.
|
||||
pub focus: Option<String>,
|
||||
/// What to gloss, and why it is out of scope.
|
||||
pub skip: Option<String>,
|
||||
/// A reading written as a bare string before this schema existed, held
|
||||
/// unparsed.
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
/// Whether a reading is assigned or offered alongside.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ReadingRole {
|
||||
/// Assigned, and therefore fair to assess.
|
||||
#[default]
|
||||
Assigned,
|
||||
/// Offered as background. Not separately assessed.
|
||||
Supplemental,
|
||||
}
|
||||
|
||||
impl Reading {
|
||||
/// The URL for this location.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `reference` - the work this reading is inside.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `url` when given, otherwise the reference's `base_url` joined with `path`,
|
||||
/// otherwise `None`.
|
||||
pub fn resolve_url(&self, reference: &Reference) -> Option<String> {
|
||||
if let Some(url) = &self.url {
|
||||
return Some(url.clone());
|
||||
}
|
||||
let path = self.path.as_deref()?;
|
||||
let base = reference.base_url.as_deref()?;
|
||||
Some(match (base.ends_with('/'), path.starts_with('/')) {
|
||||
(true, true) => format!("{base}{}", &path[1..]),
|
||||
(false, false) => format!("{base}/{path}"),
|
||||
_ => format!("{base}{path}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// A short citation for a report: `KKW §6.1`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - the citation key, used when the reference declares no label.
|
||||
/// * `reference` - the work, for its label.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The label and locator, or the unparsed `text` for a legacy reading.
|
||||
pub fn cite(&self, key: &str, reference: &Reference) -> String {
|
||||
if let Some(text) = &self.text {
|
||||
return text.clone();
|
||||
}
|
||||
let label = reference.label.as_deref().unwrap_or(key);
|
||||
match &self.locator {
|
||||
Some(locator) => format!("{label} {locator}"),
|
||||
None => label.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes a reading as a mapping, or as a bare string when that is all it holds.
|
||||
///
|
||||
/// The string case keeps a course file that predates this schema byte-identical
|
||||
/// through a load-and-save cycle, so migrating is something you choose rather than
|
||||
/// something the tool does to your file the first time it writes it.
|
||||
impl Serialize for Reading {
|
||||
fn serialize<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.objectives.is_empty() {
|
||||
return s.serialize_str(text);
|
||||
}
|
||||
}
|
||||
let mut map = s.serialize_map(None)?;
|
||||
if let Some(v) = &self.reference {
|
||||
map.serialize_entry("ref", v)?;
|
||||
}
|
||||
if let Some(v) = &self.locator {
|
||||
map.serialize_entry("locator", v)?;
|
||||
}
|
||||
if let Some(v) = &self.path {
|
||||
map.serialize_entry("path", v)?;
|
||||
}
|
||||
if let Some(v) = &self.url {
|
||||
map.serialize_entry("url", v)?;
|
||||
}
|
||||
if self.role != ReadingRole::Assigned {
|
||||
map.serialize_entry("role", &self.role)?;
|
||||
}
|
||||
if !self.objectives.is_empty() {
|
||||
map.serialize_entry("objectives", &self.objectives)?;
|
||||
}
|
||||
if let Some(v) = &self.summary {
|
||||
map.serialize_entry("summary", v)?;
|
||||
}
|
||||
if let Some(v) = &self.focus {
|
||||
map.serialize_entry("focus", v)?;
|
||||
}
|
||||
if let Some(v) = &self.skip {
|
||||
map.serialize_entry("skip", v)?;
|
||||
}
|
||||
if let Some(v) = &self.text {
|
||||
map.serialize_entry("text", v)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts a reading written either as a mapping or as a bare string.
|
||||
///
|
||||
/// The string form is what `readings` held before this schema, so course files
|
||||
/// written against the old shape keep loading. It is the same courtesy
|
||||
/// [`yaml::flexible_string`] extends to an unquoted `schema_version: 1.0`.
|
||||
impl<'de> Deserialize<'de> for Reading {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Reading, 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)]
|
||||
role: ReadingRole,
|
||||
#[serde(default)]
|
||||
objectives: Vec<String>,
|
||||
#[serde(default)]
|
||||
summary: Option<String>,
|
||||
#[serde(default)]
|
||||
focus: Option<String>,
|
||||
#[serde(default)]
|
||||
skip: Option<String>,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
struct V;
|
||||
impl<'a> Visitor<'a> for V {
|
||||
type Value = Reading;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("a reading mapping with a `ref`, or a plain citation string")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Reading, E> {
|
||||
Ok(Reading {
|
||||
text: Some(v.to_string()),
|
||||
..Reading::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn visit_map<M: MapAccess<'a>>(self, map: M) -> std::result::Result<Reading, M::Error> {
|
||||
let m = Mapping::deserialize(de::value::MapAccessDeserializer::new(map))?;
|
||||
Ok(Reading {
|
||||
reference: m.reference,
|
||||
locator: m.locator,
|
||||
path: m.path,
|
||||
url: m.url,
|
||||
role: m.role,
|
||||
objectives: m.objectives,
|
||||
summary: m.summary,
|
||||
focus: m.focus,
|
||||
skip: m.skip,
|
||||
text: m.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
d.deserialize_any(V)
|
||||
}
|
||||
}
|
||||
|
||||
/// A learning objective.
|
||||
@@ -190,6 +594,15 @@ pub struct Objective {
|
||||
/// The lectures that develop it.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub lectures: Vec<String>,
|
||||
/// Position in teaching order, low first.
|
||||
///
|
||||
/// The registry is a map, so declaration order is lost on load, and sorting by
|
||||
/// id would put `lo-enthalpy` before `lo-first-law` when the second is a
|
||||
/// prerequisite of the first. Anything that prints objectives in the order you
|
||||
/// teach them, a lecture page above all, needs this. Objectives without it sort
|
||||
/// last, by id.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub order: Option<u32>,
|
||||
/// The highest level you intend to assess this objective at. Assembling an
|
||||
/// item above the ceiling is a warning: either the item overreaches or the
|
||||
/// ceiling needs raising.
|
||||
@@ -295,6 +708,49 @@ impl CourseFile {
|
||||
));
|
||||
}
|
||||
|
||||
// A scale with a hole in it silently drops students into no band at all,
|
||||
// and the report would show a distribution that does not sum to the
|
||||
// class. Cheaper to say so here.
|
||||
let mut seen_letters: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
let mut seen_mins: Vec<f64> = Vec::new();
|
||||
for band in &self.policy.grade_scale {
|
||||
*seen_letters.entry(band.letter.as_str()).or_insert(0) += 1;
|
||||
if !(0.0..=100.0).contains(&band.min) {
|
||||
issues.push(format!(
|
||||
"policy.grade_scale: band `{}` has min {}, which is not a percentage",
|
||||
band.letter, band.min
|
||||
));
|
||||
}
|
||||
if seen_mins.iter().any(|m| (m - band.min).abs() < 1e-9) {
|
||||
issues.push(format!(
|
||||
"policy.grade_scale: two bands start at {}%, so the lower one is unreachable",
|
||||
band.min
|
||||
));
|
||||
}
|
||||
seen_mins.push(band.min);
|
||||
}
|
||||
for (letter, n) in &seen_letters {
|
||||
if *n > 1 {
|
||||
issues.push(format!(
|
||||
"policy.grade_scale: duplicate letter `{letter}` declared {n} times"
|
||||
));
|
||||
}
|
||||
}
|
||||
if !self.policy.grade_scale.is_empty() {
|
||||
let lowest = self
|
||||
.policy
|
||||
.bands()
|
||||
.last()
|
||||
.map(|b| b.min)
|
||||
.unwrap_or(f64::INFINITY);
|
||||
if lowest > 0.0 {
|
||||
issues.push(format!(
|
||||
"policy.grade_scale: the lowest band starts at {lowest}%, so a score below \
|
||||
that falls in no band. Give the failing grade a min of 0."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let unit_ids: Vec<&String> = self.units.iter().map(|u| &u.id).collect();
|
||||
let mut unit_counts: BTreeMap<&str, usize> = BTreeMap::new();
|
||||
for u in &self.units {
|
||||
@@ -306,6 +762,25 @@ impl CourseFile {
|
||||
}
|
||||
}
|
||||
|
||||
let mut labels: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||||
for (key, reference) in &self.references {
|
||||
if reference.title.trim().is_empty() {
|
||||
issues.push(format!("reference `{key}`: empty title"));
|
||||
}
|
||||
if let Some(label) = &reference.label {
|
||||
labels.entry(label.as_str()).or_default().push(key);
|
||||
}
|
||||
}
|
||||
for (label, keys) in &labels {
|
||||
if keys.len() > 1 {
|
||||
issues.push(format!(
|
||||
"references: `{label}` is the label of {}; a label has to name one work \
|
||||
because reports print it instead of the key",
|
||||
keys.join(" and ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for (id, lec) in &self.lectures {
|
||||
if lec.title.trim().is_empty() {
|
||||
issues.push(format!("lecture `{id}`: empty title"));
|
||||
@@ -315,6 +790,10 @@ impl CourseFile {
|
||||
issues.push(format!("lecture `{id}`: unknown unit `{u}`"));
|
||||
}
|
||||
}
|
||||
let mut seen: Vec<(&str, &str)> = Vec::new();
|
||||
for (index, reading) in lec.readings.iter().enumerate() {
|
||||
issues.extend(self.reading_issues(id, index, reading, &mut seen));
|
||||
}
|
||||
}
|
||||
|
||||
for (id, lo) in &self.learning_objectives {
|
||||
@@ -347,6 +826,66 @@ impl CourseFile {
|
||||
issues
|
||||
}
|
||||
|
||||
/// Checks one reading, collecting every problem with it.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `lecture` - the lecture id, for the message.
|
||||
/// * `index` - position in the lecture's list, since a reading has no id.
|
||||
/// * `reading` - the reading.
|
||||
/// * `seen` - reference and locator pairs already found in this lecture,
|
||||
/// extended as it goes.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// One message per problem.
|
||||
fn reading_issues<'a>(
|
||||
&self,
|
||||
lecture: &str,
|
||||
index: usize,
|
||||
reading: &'a Reading,
|
||||
seen: &mut Vec<(&'a str, &'a str)>,
|
||||
) -> Vec<String> {
|
||||
let mut issues = Vec::new();
|
||||
let at = format!("lecture `{lecture}` reading {}", index + 1);
|
||||
|
||||
let Some(key) = reading.reference.as_deref() else {
|
||||
if reading.text.is_none() {
|
||||
issues.push(format!(
|
||||
"{at}: needs a `ref` naming a reference, or a plain citation string"
|
||||
));
|
||||
}
|
||||
return issues;
|
||||
};
|
||||
|
||||
match self.references.get(key) {
|
||||
None => issues.push(format!("{at}: unknown reference `{key}`")),
|
||||
Some(reference) => {
|
||||
if reading.path.is_some() && reference.base_url.is_none() && reading.url.is_none() {
|
||||
issues.push(format!(
|
||||
"{at}: has a `path` but reference `{key}` has no `base_url` to join it to"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(locator) = reading.locator.as_deref() {
|
||||
if seen.contains(&(key, locator)) {
|
||||
issues.push(format!(
|
||||
"{at}: `{key} {locator}` is assigned twice in one lecture"
|
||||
));
|
||||
}
|
||||
seen.push((key, locator));
|
||||
}
|
||||
|
||||
for objective in &reading.objectives {
|
||||
if !self.learning_objectives.contains_key(objective) {
|
||||
issues.push(format!("{at}: unknown learning objective `{objective}`"));
|
||||
}
|
||||
}
|
||||
issues
|
||||
}
|
||||
|
||||
/// Detects cycles in the objective prerequisite graph.
|
||||
///
|
||||
/// A cycle would make a study-order suggestion loop forever, so it is worth
|
||||
@@ -470,7 +1009,8 @@ impl CourseFile {
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
}
|
||||
|
||||
/// Objectives in a stable teaching order: by unit as declared, then by id.
|
||||
/// Objectives in a stable teaching order: by unit as declared, then by
|
||||
/// [`Objective::order`], then by id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
@@ -490,11 +1030,148 @@ impl CourseFile {
|
||||
.as_deref()
|
||||
.and_then(|u| unit_rank.get(u).copied())
|
||||
.unwrap_or(usize::MAX);
|
||||
(rank, (*id).clone())
|
||||
(rank, lo.order.unwrap_or(u32::MAX), (*id).clone())
|
||||
});
|
||||
ids.into_iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// The objectives a lecture covers, in teaching order.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `lecture` - the lecture id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Objective ids whose `lectures` list names this lecture, ordered by
|
||||
/// [`Objective::order`] and then by id.
|
||||
pub fn lecture_objectives(&self, lecture: &str) -> Vec<&str> {
|
||||
let mut ids: Vec<&String> = self
|
||||
.learning_objectives
|
||||
.iter()
|
||||
.filter(|(_, lo)| lo.lectures.iter().any(|l| l == lecture))
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
ids.sort_by_key(|id| {
|
||||
let lo = &self.learning_objectives[*id];
|
||||
(lo.order.unwrap_or(u32::MAX), (*id).clone())
|
||||
});
|
||||
ids.into_iter().map(String::as_str).collect()
|
||||
}
|
||||
|
||||
/// Every reading that serves an objective, with the lecture it was assigned in.
|
||||
///
|
||||
/// Derived by scanning lectures rather than stored on the objective, for the
|
||||
/// same reason [`crate::history::History`] derives usage from assessment
|
||||
/// records: a second copy of an edge is a second thing to keep in step. It also
|
||||
/// puts the pointer on the volatile side, since a new edition renumbers
|
||||
/// sections but leaves your objectives alone.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `objective` - the objective id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Pairs of lecture id and reading, in lecture order then assignment order.
|
||||
pub fn readings_for_objective(&self, objective: &str) -> Vec<(&str, &Reading)> {
|
||||
let mut out = Vec::new();
|
||||
for (lecture_id, lecture) in &self.lectures {
|
||||
for reading in &lecture.readings {
|
||||
if reading.objectives.iter().any(|o| o == objective) {
|
||||
out.push((lecture_id.as_str(), reading));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Assessed objectives with no reading behind them.
|
||||
///
|
||||
/// These are the objectives a student report cannot advise on: it can say the
|
||||
/// objective was missed, but not where to go and read about it.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Objective ids in teaching order.
|
||||
pub fn objectives_without_readings(&self) -> Vec<&str> {
|
||||
let cited: std::collections::BTreeSet<&str> = self
|
||||
.lectures
|
||||
.values()
|
||||
.flat_map(|l| l.readings.iter())
|
||||
.flat_map(|r| r.objectives.iter())
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
self.objectives_in_order()
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
let (key, lo) = self.learning_objectives.get_key_value(&id)?;
|
||||
(lo.assessed && !cited.contains(key.as_str())).then_some(key.as_str())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Looks up a reference, erroring on a dangling citation key.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - the citation key.
|
||||
/// * `context` - what cited it, for the error message.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The reference.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Unresolved`] when the key is not registered.
|
||||
pub fn reference(&self, key: &str, context: &str) -> Result<&Reference> {
|
||||
self.references.get(key).ok_or_else(|| Error::Unresolved {
|
||||
kind: "reference",
|
||||
id: key.to_string(),
|
||||
context: Some(context.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Expands `{objective-id}` in a prose field to whatever the caller wants.
|
||||
///
|
||||
/// Reading notes refer to objectives in passing ("a worked instance of
|
||||
/// `{lo-vdw-additivity}`"), and a lecture page renders that as a number while a
|
||||
/// student report renders it as text. Only a name that resolves to a declared
|
||||
/// objective is treated as a placeholder, so `$U_\text{final}$` passes through
|
||||
/// untouched; that collision is the reason this is not a general template
|
||||
/// syntax.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `prose` - the field to expand.
|
||||
/// * `render` - called with each resolved objective id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The prose with resolved placeholders replaced.
|
||||
pub fn expand_objective_refs(&self, prose: &str, render: impl Fn(&str) -> String) -> String {
|
||||
let mut out = String::with_capacity(prose.len());
|
||||
let mut rest = prose;
|
||||
while let Some(open) = rest.find('{') {
|
||||
let (head, tail) = rest.split_at(open);
|
||||
out.push_str(head);
|
||||
let Some(close) = tail.find('}') else {
|
||||
out.push_str(tail);
|
||||
return out;
|
||||
};
|
||||
let name = &tail[1..close];
|
||||
if self.learning_objectives.contains_key(name) {
|
||||
out.push_str(&render(name));
|
||||
} else {
|
||||
out.push_str(&tail[..=close]);
|
||||
}
|
||||
rest = &tail[close + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// A skeleton course file for `coursebank init`.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -525,6 +1202,7 @@ impl CourseFile {
|
||||
text: "Replace this with an objective stated as a student action.".to_string(),
|
||||
unit: Some("u-intro".to_string()),
|
||||
lectures: vec!["L01".to_string()],
|
||||
order: Some(1),
|
||||
level_ceiling: Some(Level::Understand),
|
||||
prerequisites: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
@@ -549,6 +1227,7 @@ impl CourseFile {
|
||||
}],
|
||||
lectures,
|
||||
learning_objectives: los,
|
||||
references: BTreeMap::new(),
|
||||
stimuli: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -706,4 +1385,215 @@ learning_objectives:
|
||||
assert_eq!(slugify("Exam 4 -- Final!"), "exam-4-final");
|
||||
assert_eq!(slugify(" "), "");
|
||||
}
|
||||
|
||||
/// A course with one reference and two readings, one of them supplemental.
|
||||
fn with_readings() -> CourseFile {
|
||||
parse(
|
||||
r#"
|
||||
course: { code: X, title: Y, term: Z }
|
||||
references:
|
||||
kuriyan2013molecules:
|
||||
label: KKW
|
||||
role: required
|
||||
title: The molecules of life
|
||||
base_url: https://example.org/kkw/
|
||||
lectures:
|
||||
L1.1:
|
||||
title: Enthalpy
|
||||
readings:
|
||||
- ref: kuriyan2013molecules
|
||||
locator: '§6.1'
|
||||
path: '6/A/#1'
|
||||
objectives: [lo-a]
|
||||
summary: What a system is.
|
||||
focus: Fix the definitions.
|
||||
- ref: kuriyan2013molecules
|
||||
locator: '§1.9'
|
||||
path: '1/B/#9'
|
||||
role: supplemental
|
||||
objectives: [lo-b]
|
||||
learning_objectives:
|
||||
lo-a: { text: A, lectures: [L1.1], order: 1 }
|
||||
lo-b: { text: B, lectures: [L1.1], order: 2 }
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_structured_reading_parses_and_validates() {
|
||||
let c = with_readings();
|
||||
assert!(c.validate().is_empty(), "{:?}", c.validate());
|
||||
let readings = &c.lectures["L1.1"].readings;
|
||||
assert_eq!(
|
||||
readings[0].reference.as_deref(),
|
||||
Some("kuriyan2013molecules")
|
||||
);
|
||||
assert_eq!(readings[0].role, ReadingRole::Assigned);
|
||||
assert_eq!(readings[1].role, ReadingRole::Supplemental);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reading_url_is_built_from_the_reference_base() {
|
||||
let c = with_readings();
|
||||
let reference = &c.references["kuriyan2013molecules"];
|
||||
let reading = &c.lectures["L1.1"].readings[0];
|
||||
assert_eq!(
|
||||
reading.resolve_url(reference).as_deref(),
|
||||
Some("https://example.org/kkw/6/A/#1")
|
||||
);
|
||||
assert_eq!(reading.cite("kuriyan2013molecules", reference), "KKW §6.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_string_reading_still_parses_and_round_trips() {
|
||||
let c = parse(
|
||||
r#"
|
||||
course: { code: X, title: Y, term: Z }
|
||||
lectures:
|
||||
L01:
|
||||
title: One
|
||||
readings:
|
||||
- 'KKW §6.1: system and surroundings. https://example.org/1'
|
||||
"#,
|
||||
);
|
||||
let reading = &c.lectures["L01"].readings[0];
|
||||
assert!(reading.reference.is_none());
|
||||
assert_eq!(
|
||||
reading.text.as_deref(),
|
||||
Some("KKW §6.1: system and surroundings. https://example.org/1")
|
||||
);
|
||||
assert!(c.validate().is_empty());
|
||||
|
||||
// Serializing writes the string back as a string, so a load-and-save cycle
|
||||
// does not migrate a file the author has not chosen to migrate.
|
||||
let yaml = serde_yaml_ng::to_string(&c).expect("serializes");
|
||||
assert!(yaml.contains("- 'KKW §6.1: system and surroundings. https://example.org/1'"));
|
||||
assert!(!yaml.contains("text:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readings_resolve_backwards_from_an_objective() {
|
||||
let c = with_readings();
|
||||
let found = c.readings_for_objective("lo-a");
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].0, "L1.1");
|
||||
assert_eq!(found[0].1.locator.as_deref(), Some("§6.1"));
|
||||
assert!(c.readings_for_objective("lo-nobody").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_objective_with_no_reading_is_reported() {
|
||||
let mut c = with_readings();
|
||||
assert!(c.objectives_without_readings().is_empty());
|
||||
c.learning_objectives.insert(
|
||||
"lo-orphan".to_string(),
|
||||
Objective {
|
||||
text: "Orphan".to_string(),
|
||||
unit: None,
|
||||
lectures: vec!["L1.1".to_string()],
|
||||
order: Some(3),
|
||||
level_ceiling: None,
|
||||
prerequisites: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
assessed: true,
|
||||
},
|
||||
);
|
||||
assert_eq!(c.objectives_without_readings(), vec!["lo-orphan"]);
|
||||
|
||||
// An objective you teach but do not test is not a gap.
|
||||
c.learning_objectives
|
||||
.get_mut("lo-orphan")
|
||||
.expect("just inserted")
|
||||
.assessed = false;
|
||||
assert!(c.objectives_without_readings().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_order_beats_id_order_within_a_lecture() {
|
||||
let c = parse(
|
||||
r#"
|
||||
course: { code: X, title: Y, term: Z }
|
||||
lectures:
|
||||
L1.1: { title: One }
|
||||
learning_objectives:
|
||||
lo-enthalpy: { text: Third, lectures: [L1.1], order: 3 }
|
||||
lo-first-law: { text: Second, lectures: [L1.1], order: 2 }
|
||||
lo-system: { text: First, lectures: [L1.1], order: 1 }
|
||||
"#,
|
||||
);
|
||||
// Alphabetically this is enthalpy, first-law, system, which puts an
|
||||
// objective ahead of its own prerequisite.
|
||||
assert_eq!(
|
||||
c.lecture_objectives("L1.1"),
|
||||
vec!["lo-system", "lo-first-law", "lo-enthalpy"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_references_and_objectives_on_a_reading_are_reported() {
|
||||
let c = parse(
|
||||
r#"
|
||||
course: { code: X, title: Y, term: Z }
|
||||
references:
|
||||
known: { title: A book }
|
||||
lectures:
|
||||
L01:
|
||||
title: One
|
||||
readings:
|
||||
- { ref: missing, locator: '§1' }
|
||||
- { ref: known, locator: '§2', path: '2/', objectives: [lo-nope] }
|
||||
"#,
|
||||
);
|
||||
let issues = c.validate();
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|i| i.contains("unknown reference `missing`"))
|
||||
);
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|i| i.contains("unknown learning objective `lo-nope`"))
|
||||
);
|
||||
// `path` with no base_url to join it to.
|
||||
assert!(issues.iter().any(|i| i.contains("base_url")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_duplicate_label_and_a_duplicate_locator_are_reported() {
|
||||
let c = parse(
|
||||
r#"
|
||||
course: { code: X, title: Y, term: Z }
|
||||
references:
|
||||
one: { title: First, label: KKW }
|
||||
two: { title: Second, label: KKW }
|
||||
lectures:
|
||||
L01:
|
||||
title: One
|
||||
readings:
|
||||
- { ref: one, locator: '§1' }
|
||||
- { ref: one, locator: '§1' }
|
||||
"#,
|
||||
);
|
||||
let issues = c.validate();
|
||||
assert!(issues.iter().any(|i| i.contains("`KKW` is the label of")));
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|i| i.contains("assigned twice in one lecture"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_declared_objective_id_is_a_placeholder() {
|
||||
let c = with_readings();
|
||||
let expanded = c.expand_objective_refs(
|
||||
r"a worked instance of {lo-a}, where $U_\text{final}$ is unchanged, {lo-typo} too",
|
||||
|id| format!("<{id}>"),
|
||||
);
|
||||
assert_eq!(
|
||||
expanded,
|
||||
r"a worked instance of <lo-a>, where $U_\text{final}$ is unchanged, {lo-typo} too"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+312
-1
@@ -19,7 +19,11 @@
|
||||
//! it. That keeps bank files readable and reviewable in a pull request while
|
||||
//! still letting statistics accumulate across terms.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
use serde::de::{self, MapAccess, Visitor};
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::date::Date;
|
||||
use crate::hash::fingerprint;
|
||||
@@ -79,8 +83,25 @@ pub struct Item {
|
||||
|
||||
/// The answer options in canonical order. Shuffling happens at export time
|
||||
/// per form, never here, so the bank stays diffable.
|
||||
///
|
||||
/// Empty for a [`Format::OpenResponse`] item, which is answered in free text
|
||||
/// and graded from its [`Solution`] instead. A choice format must still supply
|
||||
/// at least two, which [`crate::bank::BankFile::validate`] enforces; leaving
|
||||
/// them out is reported there, with every other problem, rather than failing
|
||||
/// the parse on its own.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub options: Vec<Choice>,
|
||||
|
||||
/// 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>,
|
||||
|
||||
/// Objectives this item measures, as ids into the course registry.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub learning_objectives: Vec<String>,
|
||||
@@ -239,6 +260,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)]
|
||||
@@ -473,6 +702,7 @@ impl Item {
|
||||
stimulus: None,
|
||||
stem: stem.to_string(),
|
||||
options,
|
||||
solution: None,
|
||||
learning_objectives: Vec::new(),
|
||||
sources: Vec::new(),
|
||||
topics: Vec::new(),
|
||||
@@ -538,6 +768,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
|
||||
@@ -821,4 +1059,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:"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+1310
File diff suppressed because it is too large
Load Diff
@@ -423,17 +423,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 +669,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+158
-9
@@ -113,10 +113,51 @@ 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")`. 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 +167,84 @@ 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];
|
||||
|
||||
// An escaped pair is copied verbatim and never reconsidered, so `\$`
|
||||
// can't be mistaken for the start of math and an `\@`/`\<`/`\>` an
|
||||
// author already wrote is not escaped a second time.
|
||||
if c == '\\' && i + 1 < chars.len() {
|
||||
out.push('\\');
|
||||
out.push(chars[i + 1].1);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if c == '$' {
|
||||
match find_math_close(&chars, i) {
|
||||
Some(close) => {
|
||||
let start = chars[i + 1].0;
|
||||
let end = chars[close].0;
|
||||
out.push_str("#mi(");
|
||||
push_typst_string(&mut out, &src[start..end]);
|
||||
out.push(')');
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
None => {
|
||||
out.push_str("\\$");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match c {
|
||||
'@' => out.push_str("\\@"),
|
||||
'<' => out.push_str("\\<"),
|
||||
'>' => out.push_str("\\>"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Finds the index into `chars` of the `$` matching the opener at `open`. A
|
||||
/// backslash-escaped pair is skipped as a unit, so a `\$` inside the math span
|
||||
/// doesn't close it early.
|
||||
fn find_math_close(chars: &[(usize, char)], open: usize) -> Option<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
|
||||
}
|
||||
|
||||
/// 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 +281,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 +300,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 +487,45 @@ 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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user