Dev #1

Merged
alexm merged 9 commits from dev into main 2026-08-07 15:48:12 -04:00
79 changed files with 31370 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
[tool.bumpversion]
current_version = "26.8.0"
parse = """(?x)
(?P<release>
(?:[1-9][0-9]?)\\. # YY short year, no leading zero
(?:1[0-2]|[1-9]) # MM month 1-12, no leading zero
)
\\.(?P<patch>\\d+) # patch
"""
serialize = ["{release}.{patch}"]
allow_dirty = false
commit = true
tag = true
message = "Release {new_version}"
tag_name = "{new_version}"
tag_message = "coursebank {new_version}"
[tool.bumpversion.parts.release]
calver_format = "{YY}.{MM}"
[[tool.bumpversion.files]]
filename = "Cargo.toml"
search = 'version = "{current_version}"'
replace = 'version = "{new_version}"'
[[tool.bumpversion.files]]
filename = "pixi.toml"
search = 'version = "{current_version}"'
replace = 'version = "{new_version}"'
+25
View File
@@ -0,0 +1,25 @@
# Check http://editorconfig.org for more information
root = true
[*]
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
insert_final_newline = true
charset = utf-8
end_of_line = lf
[*.{yml, yaml}]
indent_size = 2
trim_trailing_whitespace = true
[*.md]
indent_size = 4
trim_trailing_whitespace = true
[LICENSE]
insert_final_newline = false
[*.{diff,patch}]
trim_trailing_whitespace = false
+2
View File
@@ -0,0 +1,2 @@
watch_file pixi.lock
eval "$(pixi shell-hook -e dev)"
+6
View File
@@ -0,0 +1,6 @@
## Where this project lives
The real home for this project is a self-hosted Gitea instance: **[git.scient.ing/education/coursebank](https://git.scient.ing/education/coursebank)**, where the code, issues, and history live on infrastructure I control.
GitHub only holds a pointer, so open issues, send pull requests, and clone from the link above.
I moved because I would rather own where my code lives than rent it under terms a platform can rewrite by announcement, and self-hosting puts backups, uptime, and data location back in my hands.
Old GitHub links still resolve here, so nothing breaks.
+19
View File
@@ -0,0 +1,19 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
check:
runs-on: pixi-build-rust
steps:
- uses: actions/checkout@v4
- name: Install the pinned toolchain
run: pixi install
- name: Verify (formatting, lint, tests, docs)
run: pixi run check
+67
View File
@@ -0,0 +1,67 @@
name: Deploy docs
on:
push:
branches: [main]
tags: ['*.*.*']
workflow_dispatch:
env:
SITE_SLUG: coursebank
CRATE: coursebank
jobs:
deploy:
runs-on: pixi-build-rust
steps:
- uses: actions/checkout@v4
- name: Write the channel switcher
run: |
cat > "${{ gitea.workspace }}/docs-banner.html" <<'HTML'
<div style="padding:.4rem .75rem;font:0.85rem/1.4 sans-serif;border-bottom:1px solid var(--border-color,#ddd);background:var(--sidebar-background-color,#f7f7f7);color:var(--main-color,#000)">
Docs channel:
<a id="ch-release" href="#" style="margin:0 .35rem">release</a>|
<a id="ch-nightly" href="#" style="margin:0 .35rem">nightly</a>
<script>
(function () {
var m = location.pathname.match(/^(.*\/)(release|nightly)\//);
var base = m ? m[1] : location.pathname.replace(/[^\/]*$/, "");
document.getElementById("ch-release").href = base + "release/";
document.getElementById("ch-nightly").href = base + "nightly/";
if (m) { document.getElementById("ch-" + m[2]).style.fontWeight = "bold"; }
})();
</script>
</div>
HTML
- name: Build the API docs
env:
RUSTDOCFLAGS: "--html-before-content ${{ gitea.workspace }}/docs-banner.html"
run: |
pixi install --locked
pixi run doc-build
- name: Redirect the channel root to the crate page
run: |
printf '%s\n' "<!doctype html><meta http-equiv=\"refresh\" content=\"0; url=${CRATE}/\">" > target/doc/index.html
- name: Publish the channel to /srv/www
run: |
case "${{ gitea.ref }}" in
refs/tags/*) channel=release ;;
*) channel=nightly ;;
esac
dest="/srv/www/${SITE_SLUG}/${channel}"
mkdir -p "$dest"
rsync -a --delete "target/doc/" "$dest/"
- name: Point the site root at the latest release, else nightly
run: |
root="/srv/www/${SITE_SLUG}"
if [ -d "$root/release/${CRATE}" ]; then
target="release/${CRATE}/"
else
target="nightly/${CRATE}/"
fi
printf '%s\n' "<!doctype html><meta http-equiv=\"refresh\" content=\"0; url=${target}\">" > "$root/index.html"
+71
View File
@@ -0,0 +1,71 @@
name: Nightly
on:
push:
branches: [main]
schedule:
- cron: '0 6 * * *'
workflow_dispatch:
jobs:
nightly:
runs-on: pixi-build-rust
permissions:
contents: write
env:
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
TAG: nightly
ASSET: coursebank-nightly-linux-x64.tar.gz
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install the pinned toolchain
run: pixi install
- name: Build the release binary
run: pixi run build
- name: Package
run: |
set -euo pipefail
tar -czf "$ASSET" \
-C target/release coursebank \
-C "${{ gitea.workspace }}" LICENSE.md
sha256sum "$ASSET" > "$ASSET.sha256"
- name: Publish the rolling nightly prerelease
run: |
set -euo pipefail
auth="Authorization: token ${TOKEN}"
# jq is fetched on demand; no toolchain change needed for it.
jq() { pixi exec --spec jq -- jq "$@"; }
sha="${{ gitea.sha }}"
version=$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
body="Nightly build of ${version} at $(date -u +%Y-%m-%d) (commit ${sha:0:8}). Not a stable release."
# Drop the previous nightly release and its moving tag, if present, so
# the new one points at the current commit.
existing=$(curl -fsSL -H "$auth" "${API}/releases/tags/${TAG}" 2>/dev/null || true)
if [ -n "$existing" ]; then
id=$(printf '%s' "$existing" | jq -r '.id // empty')
[ -n "$id" ] && curl -fsSL -X DELETE -H "$auth" "${API}/releases/${id}"
fi
curl -fsSL -X DELETE -H "$auth" "${API}/tags/${TAG}" 2>/dev/null || true
# Create the release at the current commit.
rid=$(curl -fsSL -X POST -H "$auth" -H "Content-Type: application/json" \
"${API}/releases" \
-d "$(jq -n --arg tag "$TAG" --arg sha "$sha" --arg body "$body" \
'{tag_name:$tag, target_commitish:$sha, name:"Nightly", body:$body, draft:false, prerelease:true}')" \
| jq -r '.id')
# Attach the tarball and its checksum.
for f in "$ASSET" "$ASSET.sha256"; do
curl -fsSL -X POST -H "$auth" \
-F "attachment=@${f}" \
"${API}/releases/${rid}/assets?name=${f}"
done
+61
View File
@@ -0,0 +1,61 @@
name: Release
on:
push:
tags:
- '*.*.*'
jobs:
release:
runs-on: pixi-build-rust
permissions:
contents: write
env:
API: ${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
TAG: ${{ gitea.ref_name }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check the tag matches the crate version
run: |
set -euo pipefail
version=$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
tag="${TAG#v}"
if [ "$tag" != "$version" ]; then
echo "tag ${TAG} does not match Cargo.toml version ${version}" >&2
exit 1
fi
- name: Install the pinned toolchain
run: pixi install
- name: Build the full bundle (binary and license notices)
run: |
pixi run setup-tools
pixi run dist
- name: Package and publish the release
run: |
set -euo pipefail
auth="Authorization: token ${TOKEN}"
jq() { pixi exec --spec jq -- jq "$@"; }
version=$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')
asset="coursebank-${version}-linux-x64.tar.gz"
tar -czf "$asset" -C dist coursebank LICENSE.md THIRD-PARTY-LICENSES.txt
sha256sum "$asset" > "$asset.sha256"
rid=$(curl -fsSL -X POST -H "$auth" -H "Content-Type: application/json" \
"${API}/releases" \
-d "$(jq -n --arg tag "$TAG" \
'{tag_name:$tag, name:$tag, body:"See CHANGELOG.md.", draft:false, prerelease:false}')" \
| jq -r '.id')
for f in "$asset" "$asset.sha256"; do
curl -fsSL -X POST -H "$auth" \
-F "attachment=@${f}" \
"${API}/releases/${rid}/assets?name=${f}"
done
+36
View File
@@ -0,0 +1,36 @@
name: Sync README to GitHub
on:
push:
branches: [main]
paths:
- 'README.md'
- '.gitea/workflows/sync-readme.yml'
- '.gitea/notice.md'
jobs:
sync:
runs-on: github-readme-sync
steps:
- name: Check out the Gitea repo
uses: actions/checkout@v4
- name: Build the combined README
run: |
cat README.md > combined.md
printf '\n\n' >> combined.md
cat .gitea/notice.md >> combined.md
- name: Push to the GitHub mirror
env:
GH_TOKEN: ${{ secrets.GH_MIRROR_TOKEN }}
run: |
git clone --depth 1 \
"https://x-access-token:${GH_TOKEN}@github.com/scienting/coursebank.git" ghmirror
cp combined.md ghmirror/README.md
cd ghmirror
git config user.name "Alex Maldonado"
git config user.email "alexm@scient.ing"
git add README.md
git diff --cached --quiet || git commit -m "Sync README from Gitea"
git push
+646
View File
@@ -0,0 +1,646 @@
preview
/dist/
/THIRD-PARTY-LICENSES.txt
# pixi environments
.pixi/*
!.pixi/config.toml
# Created by https://www.toptal.com/developers/gitignore/api/osx,windows,visualstudio,visualstudiocode,zsh,jetbrains,rust,rust-analyzer
# Edit at https://www.toptal.com/developers/gitignore?templates=osx,windows,visualstudio,visualstudiocode,zsh,jetbrains,rust,rust-analyzer
### JetBrains ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### JetBrains Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
# Sonarlint plugin
# https://plugins.jetbrains.com/plugin/7973-sonarlint
.idea/**/sonarlint/
# SonarQube Plugin
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
.idea/**/sonarIssues.xml
# Markdown Navigator plugin
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
.idea/**/markdown-navigator.xml
.idea/**/markdown-navigator-enh.xml
.idea/**/markdown-navigator/
# Cache file creation bug
# See https://youtrack.jetbrains.com/issue/JBR-2257
.idea/$CACHE_FILE$
# CodeStream plugin
# https://plugins.jetbrains.com/plugin/12206-codestream
.idea/codestream.xml
# Azure Toolkit for IntelliJ plugin
# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
.idea/**/azureSettings.xml
### OSX ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Rust ###
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
### rust-analyzer ###
# Can be generated by other build systems other than cargo (ex: bazelbuild/rust_rules)
rust-project.json
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
### Zsh ###
# Zsh compiled script + zrecompile backup
*.zwc
*.zwc.old
# Zsh completion-optimization dumpfile
*zcompdump*
# Zsh history
.zsh_history
# Zsh sessions
.zsh_sessions
# Zsh zcalc history
.zcalc_history
# A popular plugin manager's files
._zinit
.zinit_lstupd
# zdharma/zshelldoc tool's files
zsdoc/data
# robbyrussell/oh-my-zsh/plugins/per-directory-history plugin's files
# (when set-up to store the history in the local directory)
.directory_history
# MichaelAquilina/zsh-autoswitch-virtualenv plugin's files
# (for Zsh plugins using Python)
.venv
# Zunit tests' output
/tests/_output/*
!/tests/_output/.gitkeep
### VisualStudio ###
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
*.code-workspace
# Local History for Visual Studio Code
# Windows Installer files from build outputs
# JetBrains Rider
*.sln.iml
### VisualStudio Patch ###
# Additional files built by Visual Studio
# End of https://www.toptal.com/developers/gitignore/api/osx,windows,visualstudio,visualstudiocode,zsh,jetbrains,rust,rust-analyzer
+5
View File
@@ -0,0 +1,5 @@
.pixi
.DS_Store
**/cache/*
**/fonts/*
target
+11
View File
@@ -0,0 +1,11 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to calendar versioning (`YY.MM.PATCH`); see [README.md](README.md#versioning).
## [Unreleased]
### Added
- First public release.
+42
View File
@@ -0,0 +1,42 @@
[package]
name = "coursebank"
version = "26.8.0"
edition = "2024"
rust-version = "1.85"
description = "Author, assemble, administer, and analyze leveled course assessments from YAML item banks"
authors = ["Scientific Computing Studio <opensource@scient.ing>"]
license = "Prosperity-3.0.0"
readme = "README.md"
publish = false
[[bin]]
name = "coursebank"
path = "src/main.rs"
[lib]
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"
serde = { version = "1", features = ["derive"] }
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 }
[profile.release]
opt-level = 3
lto = "thin"
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
+60
View File
@@ -0,0 +1,60 @@
# The Prosperity Public License 3.0.0
Contributor: Scientific Computing Studio
Source Code: https://git.scient.ing/education/coursebank
## Purpose
This license allows you to use and share this software for noncommercial purposes for free and to try this software for commercial purposes for thirty days.
## Agreement
In order to receive this license, you have to agree to its rules.
Those rules are both obligations under that agreement and conditions to your license.
Don't do anything with this software that triggers a rule you can't or won't follow.
## Notices
Make sure everyone who gets a copy of any part of this software from you, with or without changes, also gets the text of this license and the contributor and source code lines above.
## Commercial Trial
Limit your use of this software for commercial purposes to a thirty-day trial period.
If you use this software for work, your company gets one trial period for all personnel, not one trial per person.
## Contributions Back
Developing feedback, changes, or additions that you contribute back to the contributor on the terms of a standardized public software license such as [the Blue Oak Model License 1.0.0](https://blueoakcouncil.org/license/1.0.0), [the Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0.html), [the MIT license](https://spdx.org/licenses/MIT.html), or [the two-clause BSD license](https://spdx.org/licenses/BSD-2-Clause.html) doesn't count as use for a commercial purpose.
## Personal Uses
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, doesn't count as use for a commercial purpose.
## Noncommercial Organizations
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution doesn't count as use for a commercial purpose regardless of the source of funding or obligations resulting from the funding.
## Defense
Don't make any legal claim against anyone accusing this software, with or without changes, alone or with other technology, of infringing any patent.
## Copyright
The contributor licenses you to do everything with this software that would otherwise infringe their copyright in it.
## Patent
The contributor licenses you to do everything with this software that would otherwise infringe any patents they can license or become able to license.
## Reliability
The contributor can't revoke this license.
## Excuse
You're excused for unknowingly breaking [Notices](#notices) if you take all practical steps to comply within thirty days of learning you broke the rule.
## No Liability
***As far as the law allows, this software comes as is, without any warranty or condition, and the contributor won't be liable to anyone for any damages related to this software or this license, under any kind of legal claim.***
+81
View File
@@ -0,0 +1,81 @@
# coursebank
coursebank runs the assessment side of a course as version-controlled data.
Questions, exams, grading exports, and the statistics computed from them live as YAML and Parquet files in a git repository, so they can be reviewed in pull requests and tracked as each question is reused across terms.
It is both a command-line tool and a Rust library.
The command line covers the whole loop: author items, validate and lint them, assemble an exam, export it for print or Canvas, ingest the grading export, analyze it, and write the results back onto the items so the next assembly knows how each question has behaved.
## The files
A course is a directory of four kinds of file:
| Path | Holds |
|------|-------|
| `course.yaml` | course identity, policy, learning objectives, lectures |
| `banks/*.yaml` | items, with their design intent and pooled statistics |
| `assessments/*.yaml` | what was given, in what order, on what date |
| `data/*.parquet` | one row per student per item |
The first three are hand-editable YAML meant to be read in a diff.
The response data is machine-written and stored in Parquet, so pandas, polars, DuckDB, and R can read it without this tool.
## Building
coursebank builds with Rust 1.85 or newer (edition 2024).
The repository uses [pixi](https://pixi.sh) to pin the toolchain and wrap the common tasks:
```sh
pixi run build # release binary at target/release/coursebank
pixi run install # install the binary onto your PATH
pixi run tests
pixi run check # verify: formatting, lint, tests, and doc build
```
`pixi task list` prints every task with a one-line description.
Plain cargo works too with a recent toolchain:
```sh
cargo build --release
```
## Usage
Create a course, validate it, then assemble and export an exam from the pool:
```sh
coursebank init --code "BIOSC 1540" --title "Computational Biology" --term 2026s
coursebank validate
coursebank assemble exam-4 --levels 1=6,2=8,3=10,4=6 --forms 2
coursebank export typst exam-4 --form A
```
Run `coursebank --help` for the full command set.
## Versioning
Releases use calendar versioning in `YY.MM.PATCH` form.
The first release of August 2026 is `26.8.0`; a second that month is `26.8.1`; the first in September is `26.9.0`.
Neither the year nor the month is zero-padded, because the version also has to parse as SemVer and SemVer forbids leading zeros.
The number carries no compatibility promise, so treat any release as one that can change behavior and read [`CHANGELOG.md`](CHANGELOG.md) before upgrading a live course.
`pixi run bump` cuts the next version and tags it; pushing that tag publishes a release, and every push to `main` refreshes a rolling `nightly` prerelease.
The workflows live in `.gitea/workflows/`; [RELEASING.md](RELEASING.md) explains the release flow and what to configure on the Gitea side.
## License
coursebank is source-available under the [Prosperity Public License 3.0.0](LICENSE.md).
It is not an OSI-approved open-source license.
Noncommercial use is free, and the license treats educational institutions, public research organizations, government, and similar noncommercial bodies as noncommercial regardless of how they are funded.
Commercial use gets a thirty-day trial; past that it requires a commercial license.
For a commercial license, contact `licensing@scient.ing`.
Bundled dependencies keep their own licenses, reproduced in `THIRD-PARTY-LICENSES.txt` in each release.
## Contributing
If you want to contribute, open an issue first.
Contributed code will need a contributor license agreement so it can ship under both the free and the commercial license.
That agreement is not in place yet, so outside patches cannot be merged for now.
+77
View File
@@ -0,0 +1,77 @@
# Releasing
This is the maintainer's guide to versioning and the release automation.
End users do not need it; they want the [README](README.md) and the setup guide.
## Versioning
Releases are dated, not semantic. The version is `YY.MM.PATCH`:
- `YY` is the two-digit year. 2026 is `26`.
- `MM` is the month with no leading zero. August is `8`, not `08`.
The version has to parse as SemVer (Cargo insists), and SemVer rejects leading zeros in a numeric field, so `26.08.0` is invalid and `26.8.0` is the form to use.
- `PATCH` starts at `0` in a new month and counts up for any further releases that month.
So the sequence across a few releases reads `26.8.0`, `26.8.1`, `26.9.0`.
These sort correctly both as dates and as SemVer, because each field is compared as a number.
The number is a date, not a compatibility contract.
Any release can change behavior; the [CHANGELOG](CHANGELOG.md) is where that is written down.
This is separate from `rust-version` in `Cargo.toml`, which is the oldest Rust that compiles the crate (currently `1.85`, the edition-2024 floor).
The pixi manifest pins a specific recent toolchain for reproducible builds. The two numbers answer different questions and are allowed to differ.
## Cutting a stable release
The version lives in two files, `Cargo.toml` and `pixi.toml`.
bump-my-version keeps them in step and computes the next number from today's date; it is configured in `.bumpversion.toml` and installed in the `release` pixi environment.
1. Record what changed: move the `Unreleased` notes in `CHANGELOG.md` under a heading for the new version, and commit that.
A clean working tree is required, so this commit comes first.
2. Preview the number the bump would produce:
```sh
pixi run bump-show
```
3. Cut it:
```sh
pixi run bump
```
This rewrites the version in both manifests, makes a `Release <version>` commit, and tags it.
The number follows the calendar: the first release in a month is `.0`, and a later one that month is `.1`.
To see every edit before it happens, run `pixi run --environment release bump-my-version bump patch --dry-run --verbose`.
4. Push the commit and its tag:
```sh
git push --follow-tags
```
The pushed tag triggers `.gitea/workflows/release.yml`, which rebuilds from the tagged commit, checks that the tag matches the `Cargo.toml` version (the bump has just made them agree), and publishes a Gitea release with the packaged binary and its checksum attached.
A `v` prefix on the tag is tolerated if you ever tag by hand.
## The workflows
All three live in `.gitea/workflows/` and build through pixi, the same way the site deploy does, so the runner needs pixi rather than a hand-installed Rust toolchain.
Each workflow installs pixi if it is not already on the runner.
- `ci.yml` runs on every push to `main` and every pull request.
It runs `pixi run check`: formatting, clippy, the full test suite, and a docs build.
This is the gate.
- `nightly.yml` runs on every push to `main`, so the nightly build tracks the branch.
It also carries an optional daily `cron`.
It builds the binary and publishes it as a single rolling prerelease tagged `nightly`, replacing the previous one so the tag always points at the current tip.
- `release.yml` runs on a version tag.
It builds, regenerates the third-party license notices, packages the full bundle, and publishes a normal (non-prerelease) release named after the tag.
## Publishing the API docs
`.gitea/workflows/docs.yml` builds the rustdoc and publishes it in two channels: `release/`, built from a version tag, and `nightly/`, built from `main`.
The site root redirects to the latest release, falling back to nightly until the first release exists.
Each page carries a small switcher to flip between the two channels.
It runs on the `pixi-build-rust` runner, so the doc build reuses the baked toolchain and the crate cache rather than compiling cold.
That runner builds inside a container, so give its job containers access to the docs directory by adding it to the runner's `config.yaml` alongside the cache volumes.
+27
View File
@@ -0,0 +1,27 @@
coursebank — Third-Party Software Notices
==========================================
coursebank is distributed with the third-party, open-source components listed
below. coursebank's own terms are separate and are found in LICENSE.md; the
notices here cover only the bundled dependencies, each reproduced under the
license it is provided in.
Summary
-------
{{#each overview}}
{{{name}}}{{count}} component(s)
{{/each}}
{{#each licenses}}
================================================================================
{{{name}}} ({{id}})
================================================================================
Applies to:
{{#each used_by}}
- {{{crate.name}}} v{{crate.version}}{{#if crate.repository}} <{{{crate.repository}}}>{{/if}}
{{/each}}
{{{text}}}
{{/each}}
+34
View File
@@ -0,0 +1,34 @@
# Configuration for `cargo about generate`, which produces the
# THIRD-PARTY-LICENSES.txt shipped alongside every coursebank binary.
accepted = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Zlib",
"Unicode-3.0",
"Unicode-DFS-2016",
"BSL-1.0",
"CDLA-Permissive-2.0",
"0BSD",
"Unlicense",
"CC0-1.0",
]
targets = [
"x86_64-unknown-linux-gnu",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
]
ignore-build-dependencies = true
ignore-dev-dependencies = true
private = { ignore = true }
workarounds = [
"unicode-ident",
]
+127
View File
@@ -0,0 +1,127 @@
# Typst export
The tool does not write Typst documents.
It loads a Typst file you own, finds the markers in it, and injects data.
Layout is yours; the payload is the tool's.
```console
$ coursebank template dump # get the built-in templates as files
$ coursebank template list # see which template each document uses
$ coursebank template config # write templates/typst.yaml
$ coursebank export typst exam-2 # render paper, key, and answer sheet
```
## Markers
Injection points are Typst line comments, so a template is a valid `.typ` file that compiles on its own:
```typst
// coursebank:begin questions
#render-question((number: 1, stem: [Sample.], options: ()))
// coursebank:end questions
```
Everything *between* the markers is replaced.
The marker lines survive.
Two consequences:
- The bundled templates ship with sample data inside their regions, so `typst watch templates/exam.typ` works before you have exported anything.
Restyle against the sample, then export.
- **An exported document is itself a valid template.** Exporting into a file you have since restyled replaces the questions and leaves your edits alone.
This is the difference between a generator you can use twice and one you copy out of once.
A bare `// coursebank:questions` also works.
It is rewritten into a region on output, so the second export behaves like every one after it.
Malformed markers are reported all at once, with line numbers, and the export stops: an unknown slot name, an unclosed region, a stray `end`, a nested region, or the same slot claimed twice.
## Slots
| Slot | Injected |
|:--|:--|
| `meta` | `#let cb-meta = (...)`: course, assessment, form, totals, objectives |
| `questions` | one `#render-question((...))` call per printed item |
| `data` | `#let cb-data = (...)`: the metadata and the questions together |
`questions` unrolls the loop using the record's own numbering.
`data` hands you the array and gets out of the way; the bundled key and answer sheet use it because a table suits a loop better than a sequence of calls.
Use either, both, or neither.
Only slots your template actually contains are built, so nothing costs anything until it is asked for.
Each question arrives as a **single positional dictionary**, not named arguments, so turning a field on or off in the config never changes your function's signature.
Read optional fields with `q.at("level", default: none)`.
## Configuration
`templates/typst.yaml`, three layers, each overriding the last: built-in defaults for the variant, then `defaults:`, then `variants:`.
```yaml
defaults:
question_fn: render-question # the function the `questions` slot calls
content: content # `content` -> [...] | `str` -> "..." for eval()
letters: upper # upper | lower | numeric | roman | nothing
extra:
accent: "#017ab9"
font: Roboto
variants:
key:
reveal: everything
extra:
show-solutions: true
```
Keys are snake_case, matching every other coursebank YAML file.
Keys *under* `extra` are yours and reach Typst verbatim, so they conventionally use hyphens.
`coursebank template config --resolved exam` prints what a variant actually ends up with, which is the quickest way to find out which layer won.
### `extra` is the escape hatch
Anything under `extra` is carried through untouched and arrives as `extra` in the payload.
Tier colours, a font stack, a `show-solutions` flag, a watermark, column counts.
Put it there and read it in the template.
Nothing about appearance needs to be added to this crate.
### `reveal` is not cosmetic
`reveal` controls whether the payload contains the answer **at all**.
The exam variant uses `nothing`, and that means an option dictionary on the paper has no `correct` field, not `correct: false`.
A template cannot leak a field it was never given, and that stays true after someone edits the template without reading this page.
Do not set `reveal: key` on the exam variant to build a solutions copy.
Export the `key` variant.
The failure mode of the other approach is one forgotten `if` away, and it is discovered by the whole room at once.
## Template lookup
1. `--template <path>`, or `template:` in the render config
2. `templates/<assessment-id>-<variant>.typ`, a one-off layout for one exam
3. `templates/<variant>.typ`, the course's own default
4. the template compiled into the binary
`coursebank template list` prints this chain with the resolved entry marked, plus the slots each template declares.
`coursebank template dump` writes step 4 into step 3.
## JSON
`coursebank export typst exam-2 --json` also writes the payload as JSON, for a template that reads `json("exam-2-A.json")` instead of taking an injected region.
Key spellings are identical between the two paths (`level-name`, not `level_name`), so a template can move between them without edits.
JSON has no content type, so set `content: str` and `eval(q.stem, mode: "markup")` if you use this path.
## What is still guaranteed
**The key matches its paper.** Option order comes from the form's recorded seed via `select::option_order`, never from anything stored, and the paper, key, and answer sheet are built from one payload.
Every export of form B agrees with every other.
**Question numbers are the recorded ones.**
Not positions on the page.
The recorded number is the join key to every grading export and response row; renumbering after a drop breaks that join silently, and the symptom is item statistics attributed to the wrong question.
`number-from-record: false` exists but you almost certainly want it left alone.
## Advisory warnings
`export typst` exits `2` and prints warnings, without refusing to write, when:
- a stem or option has unbalanced `[` `]`, which would otherwise surface as a Typst parse error somewhere downstream of the item that caused it, with no way for the compiler to name the question
- a template declares no markers at all, so nothing was injected
+221
View File
@@ -0,0 +1,221 @@
# Authoring items
An item is a question plus two things a question does not normally carry: what you predicted about it before anyone answered, and what happened when they did.
Keeping those next to each other is what turns a pile of questions into an instrument you can improve, because every administration produces a prediction you can check.
## A bank
```console
$ coursebank bank new sequence-analysis --title "Sequence analysis"
wrote banks/sequence-analysis.yaml
```
A bank is a topic grouping, not a unit of reuse. Items are drawn across banks by blueprint, so split banks by whatever makes them easy to edit.
One per unit is a reasonable default.
The header declares scope and defaults:
```yaml
bank:
id: sequence-analysis
title: Sequence analysis
scope:
units: [u1]
lectures: [l09, l10, l11]
defaults:
author: Alex Maldonado
options_per_item: 5
topics: [alignment]
```
`defaults` fills in fields you would otherwise repeat on every item.
## The smallest item that validates
```yaml
items:
- id: q-align-recall-001
version: 1
status: approved
level: 1
cognitive_process: recall
format: single_best_answer
title: Needleman-Wunsch vs Smith-Waterman
stem: |
Which alignment algorithm guarantees an optimal *local* alignment between two sequences?
options:
- id: A
text: Needleman#sym.minus Wunsch
correct: false
- id: B
text: Smith#sym.minus Waterman
correct: true
learning_objectives: [lo-align-algorithm]
sources:
- lecture: l09
slides: [12, 13]
```
Ids are never reused and never renumbered.
The id is the join key that ties an item to every assessment it has appeared on and every response row ever recorded for it, so `q-align-recall-001` stays that even after the stem is rewritten twice.
`status` gates assembly.
Only `approved` items can be drawn onto a graded assessment, and approval requires the item to be fully specified: a cognitive process, an objective, a source, and a key.
Draft items are visible to `lint` and invisible to `assemble`.
`level` and `cognitive_process` are checked against each other.
`level: 1` with `cognitive_process: evaluate` is an error, not a warning, because one of the two is wrong and the tool cannot tell which.
## Markup
Stems are written in a small markup that is a subset of Typst with a few Markdown conveniences, because chemistry and biology need subscripts, arrows, and Greek letters, and typing HTML entities into YAML by hand is miserable.
```yaml
stem: >
A reaction proceeds at 37#sym.degree C with #sym.delta G = #sym.minus 12
kJ/mol. Rate increases *linearly* with `[S]` below K_m.
```
`#sym.arrow.r`, `#sym.alpha`, `#sym.gt.eq`, and the rest of the table render as arrows and Greek in all three outputs.
Emphasis uses `*bold*` and `_italic_`, and backticks give monospace.
The same source becomes HTML for Canvas, Typst for print, and plain text for CSV, so you write it once.
## Distractors that earn their place
The optional fields on an option are what separate a designed distractor from filler:
```yaml
- id: A
text: Needleman#sym.minus Wunsch
correct: false
misconception: |
They remember that both are dynamic programming and pick the more familiar name without distinguishing global from local
error_type: recall_confusion
explanation: |
Needleman#sym.minus Wunsch is the global algorithm; it aligns the full length of both sequences.
feedback_student: |
Needleman#sym.minus Wunsch is the global algorithm.
Both use dynamic programming, so the distinction to hold onto is what happens at the matrix boundaries and where the traceback starts.
```
When a third of the cohort picks that option, you know what they were thinking, and the student report can tell each of them specifically rather than saying "incorrect, the answer was B."
`error_type` is one of thirteen categories, which is what lets cohort analysis say the class is losing points to dropped steps rather than to terminology.
`explanation` is for you. `feedback_student` is released to students afterwards and is the text a report shows someone who chose that option. `misconception` is used for both when neither of the others is written, so a partly-authored item degrades gracefully instead of producing a blank.
### Partial credit
A wrong option that is defensible can earn credit, but only with the argument written down:
```yaml
- id: C
text: Nothing can be said without replicates
correct: false
credit: 0.5
defensible: true
defense: >
A descriptive question about a single pair of libraries admits this
reading, so it earns half credit rather than zero.
```
`defense` is required whenever credit goes to a wrong option.
That is deliberate.
Partial credit decided in the moment and never recorded becomes a decision you cannot reconstruct next term, and then you relitigate it with the next student who asks.
The course policy's `partial_credit_floor_level` applies here.
Credit awarded below that level is flagged, on the theory that a reasonable wrong answer to a recall question means the question is unclear.
## Predictions
The `design` block is what you think before anyone sits the exam:
```yaml
design:
expected_difficulty: 0.72
expected_discrimination: moderate
expected_time_seconds: 55
rationale: |
Recall of a named distinction taught in one slide.
Most of the cohort should get it; the ones who miss it are confusing the two algorithms rather than failing to recall either.
```
`expected_time_seconds` summed over a form is how you check that an exam fits the period, which is the most common way a well-written exam goes wrong.
The other two are checkable predictions.
After the exam, `lint` compares them against what happened and reports the misses.
An item you expected to be easy that two thirds of the class missed is either mis-taught or mis-written, and either way you want to be told.
## Statistics come back
You do not write the `calibration` block.
`coursebank calibrate` does, after `ingest` and `analyze`:
```yaml
calibration:
administrations: [exam-2-2026s, exam-2-2025s]
updated: 2026-04-02
fingerprint: 8f3a2c...
n_examinees: 47
p_value: 0.68
point_biserial: 0.31
flags: []
```
Calibration is cumulative rather than per administration.
Raw per-response data lives in the Parquet tables under `data/`, which are much better at holding it, and the item's YAML keeps the rolled-up estimate plus a list of which administrations went into it.
Bank files stay readable in a pull request while statistics accumulate across terms.
Twenty-four students tells you very little; ninety-six across four terms tells you something.
The `fingerprint` is why this is safe.
It covers only what a student saw: the stem, the option text, and the key.
Retag an item's metadata and the pooled statistics stay valid.
Reword the stem and the fingerprint changes, the numbers are marked stale, and the linter says so rather than letting you trust a p-value from a question that no longer exists.
## Lint before you commit
```console
$ coursebank lint
banks/sequence-analysis.yaml
q-align-gap-002 cue-uneven-length the key is 1.8x the average distractor length (94 vs 52)
q-dock-analyze-002 clarity-stem-length stem runs 84 words
2 finding(s)
```
`coursebank lint --rules` lists every rule with its code.
Silence one you disagree with; the codes exist so that disagreeing is a configuration change rather than a reason to stop running the linter.
## Checking a bank from Rust
```rust,no_run
use coursebank::bank::BankFile;
use coursebank::Status;
# fn main() -> coursebank::Result<()> {
let bank = BankFile::load(std::path::Path::new("banks/sequence-analysis.yaml"))?;
let approved = bank
.items
.iter()
.filter(|item| item.status == Status::Approved)
.count();
println!("{approved} of {} items are assemblable", bank.items.len());
for item in &bank.items {
if let Some(calibration) = &item.calibration {
if !item.calibration_is_current() {
println!("{}: statistics predate the current wording", item.id);
} else if let Some(p) = calibration.p_value {
println!("{}: p = {p:.2}", item.id);
}
}
}
# Ok(())
# }
```
## Next
[`first_exam`](crate::guide::first_exam) draws a form from this bank and follows it through grading.
+288
View File
@@ -0,0 +1,288 @@
# One exam, end to end
This follows a single exam from blueprint to student report, using the sequence analysis and docking banks.
It assumes a course directory with approved items in it; if you do not have one, [`setup`](crate::guide::setup) and [`authoring`](crate::guide::authoring) build one.
The loop:
```text
author items ──▶ validate ──▶ lint ──▶ assemble ──▶ export ───┐
▲ │
│ administer
│ │
calibrate ◀── analyze ◀── ingest ◀───────────────────────────┘
└──▶ report (students and cohort)
```
The arrow back from `calibrate` to authoring is the point of the design.
Statistics land on the item, so they are there the next time you consider using it.
## Draw a form
Describe the exam you want by level, not by item:
```console
$ coursebank assemble exam-2 \
--title "Exam 2 — Sequence analysis and docking" \
--kind exam --date 2026-03-24 --platform paper \
--levels 1=2,2=1,3=2,4=1 --bonus 5=1 \
--require lo-align-scoring=2,lo-dock-scoring=1 \
--max-per-bank 4 --cooldown 180 --forms 2 --seed 20260324
```
`--levels 1=2,2=1,3=2,4=1` asks for six scored items across four cognitive levels.
`--bonus 5=1` adds one level-5 item outside the scored total, which is where level-5 work belongs on a timed multiple-choice paper.
`--require` sets floors per objective, so an exam cannot accidentally measure the scoring objective with a single question.
`--cooldown 180` avoids items used in the last six months, computed by scanning assessment records rather than by consulting a separate ledger.
There is no ledger file, because a ledger duplicates what the records must already get right and then drifts from it.
Use `--dry-run` first.
It prints the draw without writing anything, and a blueprint that cannot be satisfied tells you which constraint failed rather than silently returning fewer items.
What lands in `assessments/exam-2.yaml` is a record of what happened, not a plan:
```yaml
items:
- number: 1
item: sequence-analysis::q-align-recall-001
version: 1
points: 1.5
key: [B]
level: 1
learning_objectives: [lo-align-algorithm]
```
Level and objectives are denormalized onto the placement so the record reads standalone in five years, whatever the bank says by then.
## Check it against the blueprint
```console
$ coursebank assessment show exam-2
Exam 2 — Sequence analysis and docking 2026-03-24 paper 50 min
6 scored items, 9.0 points; 1 bonus item, 1.5 points
levels: 1×2 2×1 3×2 4×1
estimated time: 41 minutes of 50 allowed
blueprint: satisfied
```
The time estimate sums each item's `expected_time_seconds`, falling back to a level-based guess for items with no `design` block.
An exam that does not fit the period is the most common way a well-written exam goes wrong, and it is invisible until you are standing in the room.
## Export
Two forms with shuffled options, plus a key and a bubble sheet for each:
```console
$ coursebank export typst exam-2 --form all
wrote build/exam-2-A.typ (from built-in)
wrote build/exam-2-A-key.typ (from built-in)
wrote build/exam-2-A-answer-sheet.typ (from built-in)
wrote build/exam-2-B.typ (from built-in)
...
```
Option order comes from each form's recorded seed, never from anything stored, so form B's key is generated from the same permutation that produced form B's paper.
A key that disagrees with its paper is discovered by twenty-five students at once.
The `(from built-in)` note means no template override was found.
`coursebank template dump` writes the defaults into `templates/` so you can restyle them; see [`typst_export`](crate::guide::typst_export).
Compile with `pixi run -e docs typst compile build/exam-2-A.typ`.
For a Canvas quiz instead:
```console
$ coursebank export qti exam-2 --form A
wrote build/exam-2-A.zip
Import in Canvas: Settings -> Import Course Content -> QTI .zip file
```
## Ingest the grading export
After the exam, read the grader's output into the response store:
```console
$ coursebank ingest gradescope grading/exam-2/ \
--assessment exam-2 --form A --pseudonymize --salt-file ~/.coursebank-salt
read 24 students × 7 items = 168 rows
wrote data/exam-2-2026s.parquet
```
`--pseudonymize` replaces student identifiers with HMAC pseudonyms keyed by a salt you keep outside the repository.
Without the salt, hashed ids can be reversed by brute force over a class roster; with the salt committed next to them, so can they.
The generated `.gitignore` excludes `*.salt` for that reason.
Use `--dry-run` on a new export format.
Gradescope's per-question CSVs vary, and parsing 168 rows wrong is easier to see in a report than in a Parquet file.
## Analyze
```console
$ coursebank analyze items --assessment exam-2
# p rpb flags
1 0.88 0.21
2 0.71 0.34
3 0.46 0.09 low-discrimination
4 0.63 0.41
5 0.54 0.18 ambiguous
6 0.29 -0.12 negative-discrimination
7 0.21 0.15 bonus
reliability: KR-20 = 0.61 (24 examinees, 6 scored items)
Caution: with 6 items, reliability is limited by test length as much as by
item quality.
3 item(s) need revision
```
Read the corrected point-biserial first.
It correlates each item against the total of the *other* items, which answers the question you actually care about: did the students who knew the material get this right? A negative value almost always means the key is wrong, so check that before rewriting anything.
Item 6 above is the one to look at tonight.
Item 3's low discrimination is expected if it is an anchor item and worth investigating if it sits at level 3 or higher.
Every statistic computed from a class of twenty-four is reported with the caveat it deserves rather than three decimal places of false precision.
For a fuller picture:
```console
$ coursebank analyze irt --assessment exam-2 --model 2pl
$ coursebank analyze students --assessment exam-2
```
## Report
```console
$ coursebank report students --assessment exam-2
wrote 24 report(s) to reports/exam-2/
$ coursebank report cohort --assessment exam-2
wrote reports/exam-2-cohort.md
```
These are two documents with different content, not different tones.
The student report answers "what should I do next?" and deliberately omits correct answers, other students' data, and any numeric rank.
Where a student chose a designed distractor, it names the misconception that distractor was built to catch and points at the lecture and slides.
The cohort report answers "what should I fix?" and holds the item statistics.
## Write the statistics back
```console
$ coursebank calibrate --assessment exam-2
q-align-recall-001 p 0.71 -> 0.68 rpb 0.29 -> 0.31 n 23 -> 47
q-dock-analyze-002 NEW p 0.29 rpb -0.12 n 24 flag: negative-discrimination
...
7 item(s) would change. Re-run with --apply to write.
```
Every command that modifies a bank prints what it would change and requires `--apply`.
These are reviewed artifacts in a git repository, and a silent rewrite is not something you want to discover in a diff later.
```console
$ coursebank calibrate --assessment exam-2 --apply
```
Now the pooled statistics are on the items, and next term's `assemble` sees them.
## When grading reveals a problem
Two fields get added to the assessment record by hand, after the fact, and both stay there so that next term's analysis knows the exam was scored the way it was actually scored.
An option that turned out to be defensible earns partial credit:
```yaml
- number: 5
item: structure-and-expression::q-rnaseq-explain-004
points: 1.5
key: [B]
# Decided during grading: option C ("nothing can be said without replicates")
# is a defensible reading of a descriptive question, so it earns half credit.
credit_overrides:
C: 0.5
```
Recording it here rather than editing scores by hand means item analysis sees the same numbers the students did.
An item that was broken gets dropped:
```yaml
- number: 6
item: structure-and-expression::q-dock-analyze-002
dropped: true
```
Dropped items leave the scored matrix and are not printed on a re-export, but the placement stays in the record, because the fact that the question was asked is part of what happened.
Both of these make `analyze items` flag the item as ambiguous, which is the correct outcome.
The fix is to rewrite the stem so the narrower question is unambiguous, not to relitigate the partial credit every term.
## Doing this from Rust
The CLI is a thin wrapper.
Assembling a form programmatically:
```rust,no_run
use std::collections::BTreeMap;
use std::path::Path;
use coursebank::assessment::Blueprint;
use coursebank::history::History;
use coursebank::date::Date;
use coursebank::{select, Catalog, Level};
# fn main() -> coursebank::Result<()> {
let catalog = Catalog::load(Path::new("."))?;
let mut level_counts = BTreeMap::new();
level_counts.insert(Level::Remember, 2);
level_counts.insert(Level::Understand, 1);
level_counts.insert(Level::Apply, 2);
let blueprint = Blueprint {
level_counts,
max_per_bank: Some(4),
cooldown_days: Some(180),
seed: Some(20260324),
..Blueprint::default()
};
// Usage history is derived by scanning the assessment records, so cooldowns are
// measured against what was actually given rather than a separate ledger.
let history = History::load(&catalog.layout.assessments())?;
let selection = select::select(&catalog, &blueprint, &history, Date::new(2026, 3, 24)?)?;
for uid in &selection.scored {
println!("scored: {uid}");
}
for note in &selection.notes {
// Quotas filled by relaxing a constraint say so here.
println!("note: {note}");
}
# Ok(())
# }
```
Reading responses back and running item analysis:
```rust,no_run
use coursebank::classical::{self, Thresholds};
use coursebank::store::Store;
# fn main() -> coursebank::Result<()> {
let store = Store::open("data")?;
// `read` takes an administration id; `read_assessment` gathers every
// administration of one assessment across terms.
let responses = store.read_assessment("exam-2")?;
let analysis = classical::analyze(&responses, &Thresholds::default(), None, None);
for item in analysis.revise_queue() {
println!(
"item {}: p = {:.2}, {:?}",
item.number, item.p_value, item.flags
);
}
# Ok(())
# }
```
+119
View File
@@ -0,0 +1,119 @@
# Recipes
Short answers, for when you know the shape of the tool and want the invocation.
## Assembly
**Draw only from material I have taught.**
`--lectures l09,l10,l11`.
Combine with `--topics` and `--banks` to narrow further; the filters intersect.
**See the draw before committing to it.**
`--dry-run`.
Prints the selection and any notes about constraints that had to bend, and writes nothing.
**Reproduce a draw exactly.**
`--seed N`.
The same seed against the same pool gives the same items in the same order.
Recorded in the assessment file, so a draw stays reproducible after the fact.
**Two forms that differ only in option order.**
`--forms 2`.
Each form gets its own seed; item order is shared unless the form sets `shuffle_items`.
**A blueprint I cannot satisfy.**
The error names the level, how many items were asked for, and how many were available after filtering.
Usually the fix is a shorter cooldown or a wider lecture range, not more items.
## Reuse
```console
$ coursebank usage history q-align-recall-001
$ coursebank usage unused
```
`unused` lists approved items never placed on an assessment, which is the queue of work you already did and forgot about.
## Exports
**A printable exam.**
`coursebank export typst exam-2 --form all`.
Add `--variant key` to write only the key.
**A Canvas quiz.**
`coursebank export qti exam-2 --form A`.
Add `--no-feedback` to leave per-option feedback out of the package.
**A Markdown copy for a colleague to read.**
`coursebank export md exam-2`.
Add `--with-key` for the answers and rationales.
**Restyle the printed output.**
`coursebank template dump`, then edit `templates/exam.typ`.
See [`typst_export`](crate::guide::typst_export).
## Ingest
**Gradescope.**
`coursebank ingest gradescope grading/exam-2/ --assessment exam-2`.
Point it at the directory holding the per-question CSVs.
**Canvas.**
`coursebank ingest canvas export.csv --assessment exam-2`.
The Student Analysis export, not the gradebook.
**Keep student identities out of the repository.**
`--pseudonymize --salt-file ~/.coursebank-salt`.
Keep the salt outside the repository; the point of the salt is that hashed ids cannot be brute-forced over a class roster, which fails if the salt sits next to them.
**Check a parse before writing.** `--dry-run`.
## Analysis
| Question | Command |
|:--|:--|
| Which items misbehaved? | `analyze items --assessment exam-2` |
| How hard is each item, on a common scale? | `analyze irt --assessment exam-2 --model 2pl` |
| Which students are struggling, and with what? | `analyze students --assessment exam-2` |
| What is in the store? | `data` |
Pool across terms by passing the assessment id rather than one administration id.
Twenty-four students supports very little; ninety-six across four terms supports something.
## Reports
```console
$ coursebank report students --assessment exam-2
$ coursebank report cohort --assessment exam-2
```
The student report omits correct answers, other students' data, and any rank.
Hand it out without a second pass.
## After grading
**An option turned out to be defensible.**
Add `credit_overrides: {C: 0.5}` to the placement in the assessment record.
Do not edit scores by hand, or item analysis sees different numbers than the students did.
**An item was broken.**
Add `dropped: true` to the placement.
It leaves the scored matrix and is not printed on re-export, but the record of having asked it stays.
**Statistics onto the items.**
`coursebank calibrate --assessment exam-2`, read the diff, then `--apply`.
## Housekeeping
**Editor validation stopped working.**
`coursebank schema` rewrites the JSON Schemas.
They ship with the binary, so an upgrade can leave them stale.
**A pre-commit hook.**
`coursebank validate && coursebank lint`.
Exit code `2` means findings, `1` means the command failed, so a hook can treat them differently.
**Build without Parquet.**
`pixi run build-lean`.
The response store falls back to CSV.
Useful if you want a binary with a shorter dependency list; the tradeoff is slower reads on large stores.
+188
View File
@@ -0,0 +1,188 @@
# Setting up a course
A course is a directory in a git repository.
Nothing lives in a database, and the tool holds no state of its own, so a course you set up in 2026 opens in 2031 with whatever version of coursebank you have then.
## Make the directory
```console
$ coursebank init --code "BIOSC 1540" --title "Computational Biology" --term 2026s
wrote course.yaml
wrote 4 JSON Schema files
```
That gives you:
| Path | Holds | Written by |
|:--|:--|:--|
| `course.yaml` | identity, grading policy, objectives, lectures | you |
| `banks/` | items, with design intent and pooled statistics | you, then `calibrate` |
| `assessments/` | what was given, to whom, when | `assemble`, then you |
| `data/` | one row per student per item | `ingest` |
| `reports/` | generated Markdown and HTML | `report` |
| `build/` | exports: QTI packages, `.typ` files, PDFs | `export` |
| `schema/` | JSON Schemas for editor validation | `init`, `schema` |
`build/` and `reports/` are in the generated `.gitignore`.
The other four are the repository's content and belong in review.
Add `--with-examples` if you want a filled-in bank to read rather than an empty directory to stare at.
## Point your editor at the schemas
The schemas are the difference between authoring items and looking up field names.
With them wired in, your editor completes `cognitive_process` from the eleven legal values and underlines a typo in `learning_objectives` as you type.
Put the modeline at the top of each file:
```yaml
# yaml-language-server: $schema=../schema/bank.schema.json
```
`coursebank schema` reprints the paths and the exact line to paste.
Rerun it after upgrading, since the schemas ship with the binary.
## Fill in `course.yaml`
Four registries live here, and everything else references them by id.
### Policy
Conventions stated once instead of per assessment:
```yaml
policy:
points_per_item: 1.5
options_per_item: 5
bonus_levels: [5]
allow_partial_credit: true
partial_credit_floor_level: 3
mastery_threshold: 0.75
min_items_for_mastery: 3
```
`partial_credit_floor_level: 3` is the one to think about.
Below Apply, a defensible wrong answer usually means the item is unclear rather than that the student partly understood something.
Setting a floor makes that a rule you decided once, so it stops being an argument you have every term with a student at your desk.
### Units and lectures
Units are the coarse grouping.
Lectures carry a date and belong to a unit:
```yaml
units:
- id: u1
title: Sequence analysis
description: Alignment, scoring models, and database search.
lectures:
l09:
title: Pairwise alignment
date: 2026-02-10
unit: u1
readings:
- "Durbin et al., ch. 2"
```
The dates are what let a student report say which lecture to review, and what lets `assemble --lectures l09,l10` draw only from material you have taught.
### Learning objectives
The load-bearing registry.
An objective's wording lives in exactly one place, so rewording it updates every report that quotes it:
```yaml
learning_objectives:
lo-align-algorithm:
text: Trace the dynamic programming recurrence for a global or local alignment and explain what each term contributes.
unit: u1
lectures: [l09]
level_ceiling: 4
tags: [algorithms]
lo-align-scoring:
text: Predict how changing a substitution matrix or gap penalty changes the resulting alignment.
unit: u1
lectures: [l10]
prerequisites: [lo-align-algorithm]
level_ceiling: 4
```
Write the text in the second person and start with a verb, because reports quote it verbatim to students.
Three fields do work later that is easy to miss now.
`prerequisites` is walked backwards by student reports to suggest where to start reviewing, so a student who missed the scoring objective gets pointed at the algorithm first.
`level_ceiling` is the highest level you intend to assess the objective at; placing an item above it is a warning, which means either the item overreaches or the ceiling needs raising, and both are useful to be asked about.
`assessed: false` marks an objective you teach but measure some other way, such as by project rubric, which stops coverage reporting from flagging it as a gap on every run.
Objective ids are join keys.
Renaming one orphans every item and every stored response that referenced it, so pick names you can live with.
### Stimuli
A shared passage, table, or figure that several items ask about:
````yaml
stimuli:
s-dock-poses:
body: |
A docking run produces five poses of the same ligand. Scores are in
kcal/mol; RMSD is measured against the crystallographic pose.
```
Pose Score RMSD (Å) Cluster size
1 -9.8 6.2 3
2 -9.4 1.1 28
```
caption: Docking output for a single ligand against one receptor.
````
Items reference it with `stimulus: s-dock-poses`.
Declaring it here rather than pasting it into four items means a correction to the table happens once.
## Check it
```console
$ coursebank validate
course.yaml: ok
banks: 0 files, 0 items
assessments: 0 records
```
`validate` enforces what must be true: every reference resolves, ids are unique, keys are present, credit is in range.
It reports everything wrong in one pass instead of one problem per run, because fixing one typo per invocation is not a workflow.
`lint` is separate and advises on what is usually a mistake: an option that gives away the answer by being longer than the others, a stem with no task in it, a level that disagrees with the cognitive process.
Every rule has a code you can silence.
The split matters because a linter that blocks a commit for a style opinion gets disabled, and then you lose the validator with it.
Exit codes are meaningful.
`0` means success, `1` means the command failed, and `2` means validation or linting found something.
## Where to go next
[`authoring`](crate::guide::authoring) writes the first bank.
[`first_exam`](crate::guide::first_exam) takes an exam from blueprint to student report.
## Reading a course from Rust
The CLI is one caller.
[`Catalog::load`](crate::catalog::Catalog::load) reads a whole course directory and indexes every item by global id:
```rust,no_run
use std::path::Path;
use coursebank::Catalog;
# fn main() -> coursebank::Result<()> {
let catalog = Catalog::load(Path::new("path/to/course"))?;
println!("{} items across {} banks", catalog.entries.len(), catalog.banks.len());
// Global ids are `bank::item`. Bare ids resolve when unambiguous.
let entry = catalog.require("sequence-analysis::q-align-recall-001")?;
println!("{}", entry.item.display_title());
# Ok(())
# }
```
+3187
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
[workspace]
name = "coursebank"
version = "26.8.0"
authors = ["Scientific Computing Studio <opensource@scient.ing>"]
channels = ["conda-forge"]
platforms = ["linux-64", "osx-arm64", "osx-64"]
[dependencies]
rust = ">=1.96.0,<1.97"
c-compiler = "*"
pkg-config = "*"
[tasks]
# --- Build and run ---
build = { cmd = "cargo build --release", description = "Compile the release binary at target/release/coursebank" }
debug = { cmd = "cargo build", description = "Compile the debug binary" }
build-lean = { cmd = "cargo build --release --no-default-features", description = "Release build without the parquet feature" }
install = { cmd = "cargo install --path . --locked", description = "Install the coursebank binary onto your PATH" }
clean = { cmd = "cargo clean", description = "Remove the target directory" }
cb = { cmd = "cargo run --release --quiet --", description = "Run the CLI, e.g., `pixi run cb validate -C path/to/course`" }
# --- Quality gate ---
format = { cmd = "cargo fmt --all", description = "Reformat the source in place" }
fmt-check = { cmd = "cargo fmt --all --check", description = "Fail if the source is not formatted; does not rewrite" }
lint = { cmd = "cargo clippy --all-targets -- -D warnings", description = "Run clippy with warnings treated as errors" }
tests = { cmd = "cargo test", description = "Run all tests: unit, integration, and doctests" }
doctests = { cmd = "cargo test --doc", description = "Run only the documentation examples" }
check-docs = { cmd = "cargo test --test docs", description = "Check that included markdown fences declare a language" }
check = { depends-on = ["fmt-check", "lint", "tests", "doctests", "check-docs", "doc-build"], description = "Full verify-only gate: formatting, lint, tests, and doc build" }
# --- Documentation ---
doc = { cmd = "cargo doc --no-deps --open", depends-on = ["check-docs"], description = "Build the API docs and open them in a browser" }
doc-build = { cmd = "cargo doc --no-deps", depends-on = ["check-docs"], description = "Build the API docs without opening a browser" }
# --- Release packaging ---
setup-tools = { cmd = "cargo install cargo-about --locked --features cli", description = "Install cargo-about, which the licenses task needs" }
licenses = { cmd = "cargo about generate about.hbs -o THIRD-PARTY-LICENSES.txt", description = "Regenerate THIRD-PARTY-LICENSES.txt" }
dist = { cmd = "mkdir -p dist && cp target/release/coursebank dist/ && cp LICENSE.md THIRD-PARTY-LICENSES.txt dist/", depends-on = ["build", "licenses"], description = "Assemble a release bundle (binary and license notices) in dist/" }
[environments]
dev = ["dev"]
docs = ["docs"]
release = ["release"]
# Developer tooling: editor support, a debugger, a faster test runner, and a
# file watcher.
[feature.dev.dependencies]
rust-analyzer = "*"
lldb = "*"
cargo-nextest = "*"
[feature.dev.tasks]
nextest = { cmd = "cargo nextest run", description = "Run the test suite with nextest" }
# Typst is only needed to typeset exported .typ files by hand. The exporter
# writes .typ on its own and does not require typst to be installed.
[feature.docs.dependencies]
typst = "*"
[feature.docs.tasks]
typeset = { cmd = "typst compile", description = "Compile a .typ file to PDF" }
# Release tooling, kept in its own environment so the default and dev
# environments do not pull in Python. bump-my-version reads .bumpversion.toml,
# which encodes the YY.MM.PATCH scheme.
[feature.release.dependencies]
bump-my-version = "*"
[feature.release.tasks]
bump = { cmd = "bump-my-version bump patch", description = "Cut the next release: rewrite the version in both manifests, commit, and tag" }
bump-show = { cmd = "bump-my-version show-bump", description = "Preview the next version without changing anything" }
+39
View File
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Psychometrics: what the responses say about the items and about the students.
//!
//! ```text
//! responses ──▶ classical ──┬──▶ students ──▶ reports
//! └──▶ irt ───────┤
//! └──▶ calibrate ──▶ back onto the items
//! ```
//!
//! [`classical`] is the one that changes what you do next. The corrected
//! point-biserial answers "did the students who knew the material get this right?",
//! and a negative one almost always means a keying error or a second defensible
//! reading of the stem.
//!
//! [`irt`] adds difficulty-aware ability estimates. Its priors are on by default
//! and that is not a stylistic choice: unpenalized maximum likelihood has no finite
//! solution for an item everyone answered correctly, and real classroom exams
//! contain those routinely.
//!
//! [`students`] turns item statistics into per-objective standing, and is careful
//! about what three questions can honestly support — it classifies on the observed
//! rate and reports confidence from the Wilson interval separately.
//!
//! [`calibrate`] is the arrow back to authoring, and the reason the system
//! compounds: statistics written onto an item are there the next time you consider
//! using it. It writes to [`crate::model::bank`] files, which is the one place this
//! module reaches upward, and it always shows a diff first.
//!
//! Everything here labels its own uncertainty. A point-biserial from twenty-four
//! examinees has a standard error near 0.2, and a tool that reports it to three
//! decimals without saying so is lying by omission.
pub mod calibrate;
pub mod classical;
pub mod irt;
pub mod students;
+863
View File
@@ -0,0 +1,863 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Writing statistics back onto the items.
//!
//! This is the step that makes the whole system compound. Analysis that lives only
//! in a report gets read once; analysis written back onto the item is there the
//! next time you consider using it, and the linter can refuse to reuse a question
//! that behaved badly.
//!
//! Calibration accumulates. An item's `calibration` block is not overwritten
//! with the latest administration's numbers; the raw responses from every
//! administration are pooled and the statistics recomputed.
//!
//! Rewording resets it. Every calibration records the fingerprint of the item
//! text it was computed from. Change the stem or an option and the fingerprint
//! changes, the calibration is stale, and the linter says so. Retag the metadata
//! and nothing changes, because the fingerprint covers only what a student saw.
//! Without that rule, pooled statistics quietly become a mixture of two different
//! questions.
//!
//! Nothing is written without being shown. Calibration produces a diff you
//! read before it touches the file. The numbers here decide whether a question gets
//! used again, and a silent automated rewrite of a reviewed bank is not something
//! you want in a course repository.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use crate::assessment::AssessmentFile;
use crate::bank::BankFile;
use crate::catalog::Catalog;
use crate::classical::{self, Analysis, ItemAnalysis, Thresholds};
use crate::date::Date;
use crate::error::{Error, Result};
use crate::irt::{self, Fit};
use crate::item::{Calibration, IrtParams, OptionStat};
use crate::responses::ResponseSet;
use crate::store::Store;
use crate::taxonomy::Flag;
use crate::yaml;
/// What calibration would change about one item.
#[derive(Debug, Clone)]
pub struct Change {
/// The item's global id.
pub uid: String,
/// The bank file it lives in.
pub path: PathBuf,
/// The calibration that would be written.
pub calibration: Calibration,
/// Human-readable before-and-after lines.
pub diff: Vec<String>,
/// Whether the item previously had no calibration at all.
pub is_new: bool,
/// Whether the previous calibration was computed from different item text.
pub was_stale: bool,
}
/// The full plan, across items.
#[derive(Debug, Clone)]
pub struct Plan {
/// One entry per item whose calibration would change.
pub changes: Vec<Change>,
/// Items that were analyzed but could not be matched to a bank item.
pub unmatched: Vec<String>,
/// Cautions worth printing before the diff.
pub warnings: Vec<String>,
/// How many administrations were pooled.
pub administrations: Vec<String>,
}
impl Plan {
/// Whether anything would change.
pub fn is_empty(&self) -> bool {
self.changes.is_empty()
}
/// Renders the plan for a terminal.
///
/// # Returns
///
/// A human-readable diff.
pub fn render(&self) -> String {
let mut out = String::new();
if !self.administrations.is_empty() {
out.push_str(&format!(
"Pooling {} administration(s): {}\n\n",
self.administrations.len(),
self.administrations.join(", ")
));
}
for w in &self.warnings {
out.push_str(&format!("! {w}\n"));
}
if !self.warnings.is_empty() {
out.push('\n');
}
if self.changes.is_empty() {
out.push_str("No calibration changes.\n");
}
for c in &self.changes {
let tag = if c.is_new {
" (new)"
} else if c.was_stale {
" (previous calibration was computed from different text)"
} else {
""
};
out.push_str(&format!("{}{tag}\n", c.uid));
for line in &c.diff {
out.push_str(&format!(" {line}\n"));
}
out.push('\n');
}
if !self.unmatched.is_empty() {
out.push_str(&format!(
"{} analyzed item(s) could not be traced to a bank item and were skipped: {}\n",
self.unmatched.len(),
self.unmatched.join(", ")
));
}
out
}
}
/// Options for building a plan.
#[derive(Debug, Clone)]
pub struct Options {
/// Classical thresholds.
pub thresholds: Thresholds,
/// Whether to fit IRT and record the parameters.
pub irt: bool,
/// IRT settings.
pub irt_options: irt::Options,
/// Whether to include practice assessments. Off by default: practice
/// conditions differ enough that pooling them contaminates the statistics.
pub include_practice: bool,
/// Minimum pooled examinees before writing anything at all.
pub minimum_n: usize,
}
impl Default for Options {
fn default() -> Options {
Options {
thresholds: Thresholds::default(),
irt: true,
irt_options: irt::Options::default(),
include_practice: false,
minimum_n: 10,
}
}
}
/// Builds a calibration plan by pooling every stored administration.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `store` - the response store.
/// * `opts` - calibration options.
///
/// # Returns
///
/// The plan, which changes nothing until [`apply`] is called.
///
/// # Errors
///
/// Returns [`Error::Io`] when the store cannot be read.
pub fn plan(catalog: &Catalog, store: &Store, opts: &Options) -> Result<Plan> {
let records = AssessmentFile::load_all(&catalog.layout.assessments())?;
let by_id: BTreeMap<&str, &AssessmentFile> = records
.iter()
.map(|r| (r.assessment.id.as_str(), r))
.collect();
let stored = store.read_all()?;
let mut warnings = stored.warnings.clone();
// Analysis has to happen per administration, not per item. A corrected
// point-biserial is a correlation against the rest of that test, so it can
// only be computed while the whole administration is in hand. Pooling happens
// afterward, on the resulting numbers.
let mut per_admin: BTreeMap<String, Analysis> = BTreeMap::new();
let mut administrations: BTreeSet<String> = BTreeSet::new();
for admin in stored.administrations() {
let rows: Vec<crate::responses::Response> = stored
.rows
.iter()
.filter(|r| r.administration_id == admin)
.cloned()
.collect();
let Some(first) = rows.first() else { continue };
let record = by_id.get(first.assessment_id.as_str()).copied();
if let Some(rec) = record {
if !opts.include_practice && !rec.assessment.kind.counts_for_calibration() {
continue;
}
}
let mut set = ResponseSet::new();
set.rows = rows;
let analysis = classical::analyze(&set, &opts.thresholds, record, Some(catalog));
administrations.insert(admin.clone());
per_admin.insert(admin, analysis);
}
// Now group the per-item results by the bank item they refer to. The same item
// may have been question 7 one term and question 12 the next.
let mut by_item: BTreeMap<String, Vec<(String, ItemAnalysis)>> = BTreeMap::new();
let mut unmatched: BTreeSet<String> = BTreeSet::new();
for (admin, analysis) in &per_admin {
for item in &analysis.items {
match &item.item_ref {
Some(uid) if catalog.get(uid).is_some() => {
by_item
.entry(uid.clone())
.or_default()
.push((admin.clone(), item.clone()));
}
Some(uid) => {
unmatched.insert(uid.clone());
}
None => {
unmatched.insert(format!("{admin}#{}", item.number));
}
}
}
}
if by_item.is_empty() {
warnings.push(
"no stored responses could be traced to bank items; check that `ingest` was run with \
an assessment record so item references were recorded"
.to_string(),
);
}
// The IRT fit uses whichever administration has the most complete matrix,
// rather than a pooled matrix. Pooling responses across forms into one matrix
// would treat students who never saw an item as having missed it in a way the
// likelihood cannot distinguish from a linked design, and honest linking is a
// bigger problem than this tool should pretend to solve.
let irt_fit = if opts.irt {
best_fit(&stored, opts)
} else {
None
};
if opts.irt && irt_fit.is_none() {
warnings.push(
"IRT was requested but no single administration had enough data to fit; classical \
statistics will still be written"
.to_string(),
);
}
if let Some((admin, fit)) = &irt_fit {
warnings.extend(fit.warnings.clone());
warnings.push(format!(
"IRT parameters come from a single administration ({admin}) rather than from the \
pooled data, because linking across forms is not attempted"
));
}
let mut changes = Vec::new();
for (uid, appearances) in &by_item {
let entry = match catalog.get(uid) {
Some(e) => e,
None => continue,
};
let pooled = pool(appearances);
if pooled.n < opts.minimum_n {
continue;
}
// The IRT parameters come from whichever single administration was fitted,
// matched by the question number this item held there.
let irt_params = match &irt_fit {
Some((fitted_admin, fit)) => appearances
.iter()
.find(|(admin, _)| admin == fitted_admin)
.and_then(|(_, item)| fit.items.iter().find(|i| i.number == item.number))
.map(|i| i.to_params()),
None => None,
};
let calibration = Calibration {
administrations: appearances.iter().map(|(a, _)| a.clone()).collect(),
updated: Some(Date::today()),
fingerprint: Some(entry.item.fingerprint()),
n_examinees: Some(pooled.n),
p_value: Some(round4(pooled.p_value)),
point_biserial: pooled.point_biserial.map(round4),
discrimination_index: pooled.discrimination_index.map(round4),
mean_response_time_seconds: None,
rapid_guess_rate: None,
option_stats: pooled.option_stats.clone(),
irt: irt_params,
flags: pooled.flags.clone(),
};
let previous = entry.item.calibration.as_ref();
let diff = diff_calibration(previous, &calibration);
if diff.is_empty() {
continue;
}
changes.push(Change {
uid: uid.clone(),
path: entry.path.clone(),
calibration,
diff,
is_new: previous.is_none(),
was_stale: previous
.map(|p| p.fingerprint.as_deref() != Some(entry.item.fingerprint().as_str()))
.unwrap_or(false),
});
}
Ok(Plan {
changes,
unmatched: unmatched.into_iter().collect(),
warnings,
administrations: administrations.into_iter().collect(),
})
}
/// Pooled statistics for one item.
struct Pooled {
/// Total examinees across administrations.
n: usize,
/// Examinee-weighted difficulty.
p_value: f64,
/// Examinee-weighted point-biserial.
point_biserial: Option<f64>,
/// Examinee-weighted discrimination index.
discrimination_index: Option<f64>,
/// Pooled per-option statistics.
option_stats: BTreeMap<String, OptionStat>,
/// The union of flags raised in any administration.
flags: Vec<Flag>,
}
/// Pools one item's statistics across the administrations it appeared in.
///
/// Difficulty pools by weighted average, since a proportion correct is comparable
/// across administrations of the same text. Discrimination is also averaged rather
/// than recomputed, and that is the important subtlety: a corrected point-biserial
/// is a correlation against the rest of that test, so the only meaningful pooled
/// value is a weighted average of the within-administration correlations. Merging
/// response matrices from different exams and correlating across the whole thing
/// would produce a number that looks more precise and means less.
///
/// # Arguments
///
/// * `appearances` - the administration id and item analysis for each appearance.
///
/// # Returns
///
/// The pooled statistics.
fn pool(appearances: &[(String, ItemAnalysis)]) -> Pooled {
let mut total_n = 0usize;
let mut p_weighted = 0.0;
let mut rpb_weighted = 0.0;
let mut rpb_weight = 0.0;
let mut d_weighted = 0.0;
let mut d_weight = 0.0;
let mut flags: BTreeSet<Flag> = BTreeSet::new();
// Per-option accumulators, since option letters are stable across forms even
// when the printed order is not.
let mut rate_weighted: BTreeMap<String, f64> = BTreeMap::new();
let mut option_rpb: BTreeMap<String, (f64, f64)> = BTreeMap::new();
let mut upper: BTreeMap<String, (f64, f64)> = BTreeMap::new();
let mut lower: BTreeMap<String, (f64, f64)> = BTreeMap::new();
for (_, item) in appearances {
let n = item.n as f64;
total_n += item.n;
p_weighted += item.p_value * n;
if let Some(r) = item.point_biserial {
rpb_weighted += r * n;
rpb_weight += n;
}
if let Some(d) = item.discrimination_index {
d_weighted += d * n;
d_weight += n;
}
for f in &item.flags {
flags.insert(*f);
}
for (letter, o) in &item.options {
*rate_weighted.entry(letter.clone()).or_insert(0.0) += o.rate * n;
if let Some(r) = o.point_biserial {
let e = option_rpb.entry(letter.clone()).or_insert((0.0, 0.0));
e.0 += r * n;
e.1 += n;
}
if let Some(r) = o.upper_rate {
let e = upper.entry(letter.clone()).or_insert((0.0, 0.0));
e.0 += r * n;
e.1 += n;
}
if let Some(r) = o.lower_rate {
let e = lower.entry(letter.clone()).or_insert((0.0, 0.0));
e.0 += r * n;
e.1 += n;
}
}
}
let denominator = total_n.max(1) as f64;
let average = |m: &BTreeMap<String, (f64, f64)>, letter: &str| -> Option<f64> {
m.get(letter)
.filter(|(_, w)| *w > 0.0)
.map(|(sum, w)| round4(sum / w))
};
let option_stats: BTreeMap<String, OptionStat> = rate_weighted
.keys()
.map(|letter| {
(
letter.clone(),
OptionStat {
selection_rate: Some(round4(rate_weighted[letter] / denominator)),
point_biserial: average(&option_rpb, letter),
upper_group_rate: average(&upper, letter),
lower_group_rate: average(&lower, letter),
},
)
})
.collect();
Pooled {
n: total_n,
p_value: p_weighted / denominator,
point_biserial: if rpb_weight > 0.0 {
Some(rpb_weighted / rpb_weight)
} else {
None
},
discrimination_index: if d_weight > 0.0 {
Some(d_weighted / d_weight)
} else {
None
},
option_stats,
flags: flags.into_iter().collect(),
}
}
/// Picks the administration with the most complete matrix and fits IRT to it.
///
/// # Arguments
///
/// * `stored` - every stored response.
/// * `opts` - calibration options.
///
/// # Returns
///
/// The administration id and its fit, or `None` when none is large enough.
fn best_fit(stored: &ResponseSet, opts: &Options) -> Option<(String, Fit)> {
let mut best: Option<(String, usize, usize)> = None;
for admin in stored.administrations() {
let mut set = ResponseSet::new();
set.rows = stored
.rows
.iter()
.filter(|r| r.administration_id == admin)
.cloned()
.collect();
let m = set.matrix(false);
let cells = m.n_students() * m.n_items();
if m.n_students() < opts.minimum_n || m.n_items() < 5 {
continue;
}
if best.as_ref().map(|(_, c, _)| cells > *c).unwrap_or(true) {
best = Some((admin, cells, m.n_items()));
}
}
let (admin, _, _) = best?;
let mut set = ResponseSet::new();
set.rows = stored
.rows
.iter()
.filter(|r| r.administration_id == admin)
.cloned()
.collect();
let fit = irt::fit(&set.matrix(false), &opts.irt_options);
Some((admin, fit))
}
/// Describes the difference between two calibrations.
///
/// # Arguments
///
/// * `previous` - the existing calibration, if any.
/// * `next` - the computed calibration.
///
/// # Returns
///
/// One line per changed field; empty when nothing meaningful changed.
fn diff_calibration(previous: Option<&Calibration>, next: &Calibration) -> Vec<String> {
let mut out = Vec::new();
let show = |label: &str, before: Option<f64>, after: Option<f64>, out: &mut Vec<String>| match (
before, after,
) {
(Some(b), Some(a)) if (b - a).abs() > 5e-4 => {
out.push(format!("{label}: {b:.3} -> {a:.3}"));
}
(None, Some(a)) => out.push(format!("{label}: (none) -> {a:.3}")),
_ => {}
};
let p = previous;
show("p-value", p.and_then(|c| c.p_value), next.p_value, &mut out);
show(
"point-biserial",
p.and_then(|c| c.point_biserial),
next.point_biserial,
&mut out,
);
show(
"discrimination index",
p.and_then(|c| c.discrimination_index),
next.discrimination_index,
&mut out,
);
let before_n = p.and_then(|c| c.n_examinees).unwrap_or(0);
if let Some(n) = next.n_examinees {
if n != before_n {
out.push(format!("examinees: {before_n} -> {n}"));
}
}
let before_flags: BTreeSet<Flag> = p
.map(|c| c.flags.iter().copied().collect())
.unwrap_or_default();
let after_flags: BTreeSet<Flag> = next.flags.iter().copied().collect();
let added: Vec<&str> = after_flags
.difference(&before_flags)
.map(|f| f.as_str())
.collect();
let removed: Vec<&str> = before_flags
.difference(&after_flags)
.map(|f| f.as_str())
.collect();
if !added.is_empty() {
out.push(format!("flags added: {}", added.join(", ")));
}
if !removed.is_empty() {
out.push(format!("flags cleared: {}", removed.join(", ")));
}
match (p.and_then(|c| c.irt.as_ref()), next.irt.as_ref()) {
(Some(b), Some(a)) if (b.a - a.a).abs() > 5e-3 || (b.b - a.b).abs() > 5e-3 => {
out.push(format!(
"IRT: a {:.2} -> {:.2}, b {:+.2} -> {:+.2}",
b.a, a.a, b.b, a.b
));
}
(None, Some(a)) => out.push(format!("IRT: (none) -> a {:.2}, b {:+.2}", a.a, a.b)),
_ => {}
}
if p.map(|c| c.fingerprint.as_deref()) != Some(next.fingerprint.as_deref()) {
out.push("fingerprint updated to the current item text".to_string());
}
out
}
/// Applies a plan, rewriting the affected bank files.
///
/// Files are rewritten one at a time and each is re-read before editing, so a plan
/// built against a bank that has since changed on disk fails loudly rather than
/// clobbering the newer version.
///
/// # Arguments
///
/// * `plan` - the plan to apply.
///
/// # Returns
///
/// The bank files rewritten.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when an item in the plan is no longer in its bank,
/// and [`Error::Io`] on a write failure.
pub fn apply(plan: &Plan) -> Result<Vec<PathBuf>> {
// Group by file so each is read and written once.
let mut by_file: BTreeMap<&PathBuf, Vec<&Change>> = BTreeMap::new();
for change in &plan.changes {
by_file.entry(&change.path).or_default().push(change);
}
let mut written = Vec::new();
for (path, changes) in by_file {
let mut bank: BankFile = yaml::read(path)?;
for change in changes {
// The uid is `bank::item`; match on the item part.
let item_id = change
.uid
.split_once("::")
.map(|(_, id)| id)
.unwrap_or(&change.uid);
let target = bank.items.iter_mut().find(|i| i.id == item_id);
match target {
Some(item) => item.calibration = Some(change.calibration.clone()),
None => {
return Err(Error::Unresolved {
kind: "item",
id: change.uid.clone(),
context: Some(format!(
"{} — the bank changed since the plan was built; re-run calibration",
path.display()
)),
});
}
}
}
yaml::write(path, &bank)?;
written.push(path.clone());
}
Ok(written)
}
/// Builds a plan for a single administration, from an in-memory analysis.
///
/// Useful right after an exam, before deciding whether to drop a question.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `analysis` - the analysis of that administration.
/// * `fit` - an optional IRT fit.
///
/// # Returns
///
/// The plan.
pub fn plan_from_analysis(
catalog: &Catalog,
record: &AssessmentFile,
analysis: &Analysis,
fit: Option<&Fit>,
) -> Plan {
let admin = crate::responses::administration_id(
&catalog.course.course.code,
record
.assessment
.term
.as_deref()
.unwrap_or(&catalog.course.course.term),
&record.assessment.id,
);
let mut changes = Vec::new();
let mut unmatched = Vec::new();
for item in &analysis.items {
let Some(uid) = item.item_ref.clone() else {
unmatched.push(format!("question {}", item.number));
continue;
};
let Some(entry) = catalog.get(&uid) else {
unmatched.push(uid);
continue;
};
let irt_params: Option<IrtParams> = fit.and_then(|f| {
f.items
.iter()
.find(|i| i.number == item.number)
.map(|i| i.to_params())
});
let calibration = Calibration {
administrations: vec![admin.clone()],
updated: Some(Date::today()),
fingerprint: Some(entry.item.fingerprint()),
n_examinees: Some(item.n),
p_value: Some(round4(item.p_value)),
point_biserial: item.point_biserial.map(round4),
discrimination_index: item.discrimination_index.map(round4),
mean_response_time_seconds: None,
rapid_guess_rate: None,
option_stats: item
.options
.iter()
.map(|(letter, o)| (letter.clone(), o.to_option_stat()))
.collect(),
irt: irt_params,
flags: item.flags.clone(),
};
let previous = entry.item.calibration.as_ref();
let diff = diff_calibration(previous, &calibration);
if diff.is_empty() {
continue;
}
changes.push(Change {
uid,
path: entry.path.clone(),
calibration,
diff,
is_new: previous.is_none(),
was_stale: previous
.map(|p| p.fingerprint.as_deref() != Some(entry.item.fingerprint().as_str()))
.unwrap_or(false),
});
}
Plan {
changes,
unmatched,
warnings: analysis.warnings.clone(),
administrations: vec![admin],
}
}
/// Rounds to four decimals.
fn round4(x: f64) -> f64 {
(x * 1e4).round() / 1e4
}
#[cfg(test)]
mod tests {
use super::*;
use crate::item::IrtModel;
fn calibration(p: f64, rpb: Option<f64>, n: usize, fingerprint: &str) -> Calibration {
Calibration {
administrations: vec!["C/2026s/e1".into()],
updated: None,
fingerprint: Some(fingerprint.to_string()),
n_examinees: Some(n),
p_value: Some(p),
point_biserial: rpb,
discrimination_index: None,
mean_response_time_seconds: None,
rapid_guess_rate: None,
option_stats: BTreeMap::new(),
irt: None,
flags: Vec::new(),
}
}
#[test]
fn a_first_calibration_is_all_new() {
let next = calibration(0.7, Some(0.3), 24, "abc");
let diff = diff_calibration(None, &next);
assert!(diff.iter().any(|d| d.contains("p-value: (none)")));
assert!(diff.iter().any(|d| d.contains("examinees: 0 -> 24")));
}
#[test]
fn identical_calibrations_produce_no_diff() {
let previous = calibration(0.7, Some(0.3), 24, "abc");
let next = calibration(0.7, Some(0.3), 24, "abc");
assert!(diff_calibration(Some(&previous), &next).is_empty());
}
#[test]
fn tiny_changes_are_not_reported() {
let previous = calibration(0.7000, Some(0.30), 24, "abc");
let next = calibration(0.7001, Some(0.30), 24, "abc");
assert!(
diff_calibration(Some(&previous), &next).is_empty(),
"a change below the display precision is noise"
);
}
#[test]
fn a_changed_fingerprint_is_called_out() {
let previous = calibration(0.7, Some(0.3), 24, "old-text");
let next = calibration(0.7, Some(0.3), 24, "new-text");
let diff = diff_calibration(Some(&previous), &next);
assert!(diff.iter().any(|d| d.contains("fingerprint")));
}
#[test]
fn flag_changes_are_reported_in_both_directions() {
let mut previous = calibration(0.7, Some(0.3), 24, "abc");
previous.flags = vec![Flag::TooEasy];
let mut next = calibration(0.7, Some(0.3), 24, "abc");
next.flags = vec![Flag::NegativeDiscrimination];
let diff = diff_calibration(Some(&previous), &next);
assert!(
diff.iter()
.any(|d| d.contains("flags added") && d.contains("negative_discrimination"))
);
assert!(
diff.iter()
.any(|d| d.contains("flags cleared") && d.contains("too_easy"))
);
}
#[test]
fn irt_changes_are_reported() {
let previous = calibration(0.7, None, 24, "abc");
let mut next = calibration(0.7, None, 24, "abc");
next.irt = Some(IrtParams {
model: IrtModel::TwoPl,
a: 1.2,
b: -0.4,
c: None,
se_a: None,
se_b: None,
n: Some(24),
bayesian: true,
});
let diff = diff_calibration(Some(&previous), &next);
assert!(diff.iter().any(|d| d.contains("IRT: (none)")));
}
#[test]
fn an_empty_plan_renders_readably() {
let plan = Plan {
changes: Vec::new(),
unmatched: Vec::new(),
warnings: Vec::new(),
administrations: Vec::new(),
};
assert!(plan.is_empty());
assert!(plan.render().contains("No calibration changes"));
}
#[test]
fn a_plan_renders_its_diff_and_warnings() {
let plan = Plan {
changes: vec![Change {
uid: "bank::q-a-001".into(),
path: PathBuf::from("banks/bank.yaml"),
calibration: calibration(0.7, Some(0.3), 24, "abc"),
diff: vec!["p-value: (none) -> 0.700".into()],
is_new: true,
was_stale: false,
}],
unmatched: vec!["question 9".into()],
warnings: vec!["only 24 examinees".into()],
administrations: vec!["C/2026s/e1".into()],
};
let text = plan.render();
assert!(text.contains("bank::q-a-001 (new)"));
assert!(text.contains("p-value"));
assert!(text.contains("only 24 examinees"));
assert!(text.contains("question 9"));
assert!(text.contains("Pooling 1 administration"));
}
}
File diff suppressed because it is too large Load Diff
+1176
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Support for writing items and building assessments from them.
//!
//! The three modules here are what you interact with before an exam exists, and
//! they divide by how much authority each one has.
//!
//! [`lint`] has none. Its 26 rules are advice with stable codes, every one
//! silenceable with `--ignore`. It is deliberately separate from the validation in
//! [`crate::model::bank`], which enforces what must be true and fails. Conflating
//! the two produces a tool that either blocks you on style or lets real errors
//! through.
//!
//! [`select`] draws an assessment from a blueprint. It places objective minimums
//! before level quotas, because a coverage requirement is the constraint most
//! likely to become unsatisfiable, and it prefers least-recently-used items so a
//! bank rotates rather than converging on your favourites.
//!
//! [`jsonschema`] emits JSON Schema so an editor autocompletes the YAML. That is
//! a better authoring experience than any validator: catching `cognitve_process`
//! as you type beats reading it in a list afterward.
pub mod jsonschema;
pub mod lint;
pub mod select;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+719
View File
@@ -0,0 +1,719 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Drawing an assessment from the item pool.
//!
//! Assembly is a constrained draw, not a random sample, and the constraints are
//! the point. A blueprint says how many items at each level; the pool says which
//! items are eligible; usage history says which ones students have seen recently.
//! What comes out is a form that matches the design you intended rather than
//! whichever questions happened to be at the top of the file.
//!
//! Two ordering rules do most of the work.
//!
//! *Objective minimums come first.* If a blueprint requires two items on
//! `lo-mm-kinetics`, those are placed before the level quotas are filled by
//! anything else, because a level quota can always be filled and a coverage
//! requirement often cannot. Filling in the other order strands the requirement.
//!
//! *Within a level, prefer the least recently used item.* Never-used items go
//! first, then the oldest, then the least often used. This spreads exposure
//! across the bank instead of wearing out your favorite twelve questions, and it
//! is the mechanism that makes writing new items pay off.
//!
//! Every tie is broken by a seeded shuffle, so a draw is reproducible from the
//! seed recorded in the assessment file.
use std::collections::{BTreeMap, BTreeSet};
use crate::assessment::{Assessment, AssessmentFile, Blueprint, Form, Kind, Placement, Platform};
use crate::catalog::Catalog;
use crate::course::SCHEMA_VERSION;
use crate::date::Date;
use crate::error::{Error, Result};
use crate::history::History;
use crate::rng::Rng;
use crate::taxonomy::Level;
/// The result of a draw.
#[derive(Debug, Clone)]
pub struct Selection {
/// Scored item ids, grouped and ordered by level.
pub scored: Vec<String>,
/// Bonus item ids.
pub bonus: Vec<String>,
/// Things the caller should know: quotas filled by relaxing a constraint,
/// levels that came up short, cooldowns that had to be ignored.
pub notes: Vec<String>,
}
impl Selection {
/// Every selected id, scored then bonus.
pub fn all(&self) -> Vec<String> {
let mut v = self.scored.clone();
v.extend(self.bonus.clone());
v
}
}
/// Draws an assessment from the pool according to a blueprint.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `blueprint` - the design to satisfy.
/// * `history` - usage history, for the least-recently-used preference.
/// * `as_of` - the date of the assessment, against which cooldowns are measured.
///
/// # Returns
///
/// The selection, together with notes about any constraint that had to bend.
///
/// # Errors
///
/// Returns [`Error::Infeasible`] when a level quota cannot be met even after
/// relaxing the reuse cooldown, with a message saying how many items were
/// available and how many were asked for.
pub fn select(
catalog: &Catalog,
blueprint: &Blueprint,
history: &History,
as_of: Date,
) -> Result<Selection> {
let seed = blueprint.seed.unwrap_or(0);
let mut notes = Vec::new();
// ------------------------------------------------------------------ pool
let eligible: Vec<&crate::catalog::Entry> = catalog
.assemblable()
.into_iter()
.filter(|e| passes_filters(e, blueprint))
.collect();
if eligible.is_empty() {
return Err(Error::Infeasible(
"no approved items match the blueprint's bank, lecture, and topic filters".into(),
));
}
let cooldown = blueprint.cooldown_days.unwrap_or(0);
let mut chosen: Vec<String> = Vec::new();
let mut per_bank: BTreeMap<String, usize> = BTreeMap::new();
// ---------------------------------------------------- objective minimums
// Placed first, because a coverage requirement is the constraint most likely
// to become unsatisfiable once the level quotas are full.
for (objective, needed) in &blueprint.objective_minimums {
let mut have = 0;
let mut candidates: Vec<&crate::catalog::Entry> = eligible
.iter()
.copied()
.filter(|e| e.item.learning_objectives.iter().any(|o| o == objective))
.filter(|e| !e.item.bonus)
.collect();
rank(&mut candidates, history, seed, "objective");
for e in candidates {
if have >= *needed {
break;
}
if chosen.contains(&e.uid) {
have += 1;
continue;
}
if !bank_has_room(&per_bank, &e.bank, blueprint) {
continue;
}
if cooldown > 0 && history.in_cooldown(&e.uid, cooldown, as_of) {
continue;
}
chosen.push(e.uid.clone());
*per_bank.entry(e.bank.clone()).or_insert(0) += 1;
have += 1;
}
if have < *needed {
notes.push(format!(
"objective `{objective}` requires {needed} item(s) but only {have} could be \
placed; write more items on it or lower the requirement"
));
}
}
// ------------------------------------------------------- level quotas
let mut scored: Vec<String> = Vec::new();
for (level, want) in &blueprint.level_counts {
if *want == 0 {
continue;
}
let (picked, level_notes) = fill_level(
&eligible,
*level,
*want,
false,
history,
seed,
cooldown,
as_of,
&mut chosen,
&mut per_bank,
blueprint,
)?;
scored.extend(picked);
notes.extend(level_notes);
}
// Items placed to satisfy an objective minimum are scored items too, and
// they must appear exactly once.
for uid in &chosen {
if !scored.contains(uid) {
if let Some(e) = catalog.get(uid) {
if !e.item.bonus {
scored.push(uid.clone());
}
}
}
}
// ------------------------------------------------------------ bonus items
let mut bonus: Vec<String> = Vec::new();
for (level, want) in &blueprint.bonus_counts {
if *want == 0 {
continue;
}
let (picked, level_notes) = fill_level(
&eligible,
*level,
*want,
true,
history,
seed,
cooldown,
as_of,
&mut chosen,
&mut per_bank,
blueprint,
)?;
bonus.extend(picked);
notes.extend(level_notes);
}
// Order the scored items by level so the form ramps in difficulty. Students
// meet the recall items first, which is both kinder and better measurement:
// an early hard item costs time that later easy items cannot recover.
scored.sort_by_key(|uid| {
let e = catalog.get(uid);
(
e.map(|e| e.item.level.code()).unwrap_or(9),
e.map(|e| e.uid.clone()).unwrap_or_default(),
)
});
Ok(Selection {
scored,
bonus,
notes,
})
}
/// Fills one level's quota.
///
/// Cooldowns are relaxed rather than allowed to fail the draw, because an exam
/// that must be given on Thursday is better assembled from a recently used item
/// with a warning than not assembled at all.
///
/// # Arguments
///
/// * `eligible` - the filtered pool.
/// * `level` - the level to fill.
/// * `want` - how many items are needed.
/// * `bonus` - whether to draw bonus items.
/// * `history` - usage history.
/// * `seed` - the tie-breaking seed.
/// * `cooldown` - the reuse cooldown in days, 0 to disable.
/// * `as_of` - the assessment date.
/// * `chosen` - ids already taken, updated in place.
/// * `per_bank` - per-bank counts, updated in place.
/// * `blueprint` - for the per-bank cap.
///
/// # Returns
///
/// The ids picked and any notes.
///
/// # Errors
///
/// Returns [`Error::Infeasible`] when the level cannot be filled at all.
#[allow(clippy::too_many_arguments)]
fn fill_level(
eligible: &[&crate::catalog::Entry],
level: Level,
want: usize,
bonus: bool,
history: &History,
seed: u64,
cooldown: i64,
as_of: Date,
chosen: &mut Vec<String>,
per_bank: &mut BTreeMap<String, usize>,
blueprint: &Blueprint,
) -> Result<(Vec<String>, Vec<String>)> {
let mut notes = Vec::new();
let mut candidates: Vec<&crate::catalog::Entry> = eligible
.iter()
.copied()
.filter(|e| e.item.level == level && e.item.bonus == bonus)
.collect();
let pool_size = candidates.len();
if pool_size < want {
return Err(Error::Infeasible(format!(
"level {} needs {want} {}item(s) but only {pool_size} approved item(s) are \
available; write more or lower the quota",
level.code(),
if bonus { "bonus " } else { "" }
)));
}
rank(
&mut candidates,
history,
seed,
&format!("L{}", level.code()),
);
let mut picked = Vec::new();
let mut skipped_for_cooldown = 0usize;
let mut skipped_for_bank = 0usize;
// Two passes: honor every constraint, then relax the cooldown if short.
for relax in [false, true] {
for e in &candidates {
if picked.len() >= want {
break;
}
if chosen.contains(&e.uid) {
continue;
}
if !bank_has_room(per_bank, &e.bank, blueprint) {
if !relax {
skipped_for_bank += 1;
}
continue;
}
if !relax && cooldown > 0 && history.in_cooldown(&e.uid, cooldown, as_of) {
skipped_for_cooldown += 1;
continue;
}
if relax && cooldown > 0 && history.in_cooldown(&e.uid, cooldown, as_of) {
notes.push(format!(
"level {}: reused `{}` inside the {cooldown}-day cooldown (last used {})",
level.code(),
e.uid,
history
.last_used(&e.uid)
.map(|d| d.to_string())
.unwrap_or_else(|| "unknown".into())
));
}
picked.push(e.uid.clone());
chosen.push(e.uid.clone());
*per_bank.entry(e.bank.clone()).or_insert(0) += 1;
}
if picked.len() >= want {
break;
}
}
if picked.len() < want {
return Err(Error::Infeasible(format!(
"level {} needs {want} item(s); {pool_size} exist but only {} could be placed \
({skipped_for_cooldown} blocked by the reuse cooldown, {skipped_for_bank} by the \
per-bank cap)",
level.code(),
picked.len()
)));
}
Ok((picked, notes))
}
/// Whether an entry passes the blueprint's inclusion filters.
///
/// # Arguments
///
/// * `e` - the entry.
/// * `b` - the blueprint.
///
/// # Returns
///
/// `true` when the item is eligible.
fn passes_filters(e: &crate::catalog::Entry, b: &Blueprint) -> bool {
if !b.banks.is_empty() && !b.banks.contains(&e.bank) {
return false;
}
if !b.lectures.is_empty()
&& !e
.item
.sources
.iter()
.any(|s| b.lectures.contains(&s.lecture))
{
return false;
}
if !b.topics.is_empty() && !e.item.topics.iter().any(|t| b.topics.contains(t)) {
return false;
}
true
}
/// Whether a bank may contribute another item.
///
/// # Arguments
///
/// * `per_bank` - counts so far.
/// * `bank` - the bank in question.
/// * `b` - the blueprint, for the cap.
///
/// # Returns
///
/// `true` when there is room.
fn bank_has_room(per_bank: &BTreeMap<String, usize>, bank: &str, b: &Blueprint) -> bool {
match b.max_per_bank {
Some(cap) => per_bank.get(bank).copied().unwrap_or(0) < cap,
None => true,
}
}
/// Orders candidates least-recently-used first, with a seeded tie-break.
///
/// # Arguments
///
/// * `candidates` - the candidates to order, sorted in place.
/// * `history` - usage history.
/// * `seed` - the tie-breaking seed.
/// * `salt` - distinguishes the shuffles used for different levels, so two
/// levels drawing from overlapping pools do not tie-break identically.
fn rank(candidates: &mut Vec<&crate::catalog::Entry>, history: &History, seed: u64, salt: &str) {
// Shuffle first so the sort's stability turns into a random tie-break.
let mut rng = Rng::from_label(&format!("{seed}/{salt}"));
rng.shuffle(candidates);
candidates.sort_by_key(|e| {
let last = history.last_used(&e.uid);
(
// Never used sorts before ever used.
if last.is_some() { 1 } else { 0 },
last.map(|d| d.days_since_epoch()).unwrap_or(i64::MIN),
history.use_count(&e.uid),
)
});
}
/// Turns a selection into an assessment record ready to write.
///
/// The record captures the key and fingerprint of every item *as of now*, which
/// is what makes later analysis honest about drift.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `selection` - the draw.
/// * `id` - the assessment id.
/// * `title` - the printed title.
/// * `kind` - the kind of assessment.
/// * `date` - the administration date.
/// * `platform` - where it will be administered.
/// * `blueprint` - the blueprint used, recorded for later comparison.
/// * `forms` - how many alternate forms to declare.
///
/// # Returns
///
/// The assessment record.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] if a selected id has vanished from the catalog.
#[allow(clippy::too_many_arguments)]
pub fn to_record(
catalog: &Catalog,
selection: &Selection,
id: &str,
title: &str,
kind: Kind,
date: Date,
platform: Platform,
blueprint: &Blueprint,
forms: usize,
) -> Result<AssessmentFile> {
let default_points = catalog.course.policy.points_per_item;
let mut items = Vec::new();
for (number, (uid, is_bonus)) in (1u32..).zip(
selection
.scored
.iter()
.map(|u| (u, false))
.chain(selection.bonus.iter().map(|u| (u, true))),
) {
let e = catalog.require(uid)?;
items.push(Placement {
number,
item: uid.clone(),
version: Some(e.item.version),
fingerprint: Some(e.item.fingerprint()),
points: Some(e.item.points(default_points)),
bonus: is_bonus || e.item.bonus,
key: e.item.key_letters(),
level: Some(e.item.level),
learning_objectives: e.item.learning_objectives.clone(),
credit_overrides: BTreeMap::new(),
dropped: false,
});
}
let form_list: Vec<Form> = (0..forms)
.map(|i| {
let label = form_label(i);
Form {
seed: Rng::from_label(&format!("{id}/form-{label}")).next_u64(),
id: label,
shuffle_items: false,
shuffle_options: true,
}
})
.collect();
Ok(AssessmentFile {
schema_version: SCHEMA_VERSION.to_string(),
assessment: Assessment {
id: id.to_string(),
title: title.to_string(),
term: Some(catalog.course.course.term.clone()),
kind,
date: Some(date),
platform,
minutes_allowed: None,
attempts: None,
shuffle: None,
scoring_policy: None,
instructions: None,
notes: None,
},
blueprint: Some(blueprint.clone()),
forms: form_list,
items,
})
}
/// The label for the nth form: A, B, ... Z, AA, AB, ...
///
/// # Arguments
///
/// * `i` - the zero-based form index.
///
/// # Returns
///
/// The label.
fn form_label(i: usize) -> String {
let mut n = i;
let mut out = String::new();
loop {
out.insert(0, (b'A' + (n % 26) as u8) as char);
if n < 26 {
break;
}
n = n / 26 - 1;
}
out
}
/// The order items appear in on one form.
///
/// Permuting a form is a display concern, so it is computed on demand from the
/// recorded seed rather than stored. That keeps the record small and guarantees
/// every export of form B agrees.
///
/// # Arguments
///
/// * `record` - the assessment record.
/// * `form` - the form to lay out.
///
/// # Returns
///
/// Placements in printed order for this form. The `number` field is left as
/// recorded, since it is the join key to grading data and must not change
/// between forms; use the position in the returned vector for what to print.
pub fn layout(record: &AssessmentFile, form: &Form) -> Vec<Placement> {
// Bonus items always come last, whatever the shuffle says: they are outside
// the scored total, and burying one mid-form invites students to spend time
// there that the graded questions needed.
let mut scored: Vec<Placement> = record.items.iter().filter(|p| !p.bonus).cloned().collect();
let bonus: Vec<Placement> = record.items.iter().filter(|p| p.bonus).cloned().collect();
if form.shuffle_items {
let mut rng = Rng::new(form.seed);
rng.shuffle(&mut scored);
}
scored.into_iter().chain(bonus).collect()
}
/// The option order for one item on one form.
///
/// # Arguments
///
/// * `form` - the form.
/// * `uid` - the item's global id, which salts the permutation so two items on
/// the same form do not permute identically.
/// * `n` - the number of options.
///
/// # Returns
///
/// A permutation of `0..n`.
pub fn option_order(form: &Form, uid: &str, n: usize) -> Vec<usize> {
let mut order: Vec<usize> = (0..n).collect();
if form.shuffle_options && n > 1 {
let mut rng = Rng::from_label(&format!("{}/{}/{}", form.seed, form.id, uid));
rng.shuffle(&mut order);
}
order
}
/// Compares a record against its blueprint.
///
/// # Arguments
///
/// * `record` - the assessment record.
///
/// # Returns
///
/// One message per discrepancy, empty when the form matches the design.
pub fn check_blueprint(record: &AssessmentFile) -> Vec<String> {
let Some(bp) = &record.blueprint else {
return vec!["the record carries no blueprint to check against".into()];
};
let actual = record.level_counts();
let mut out = Vec::new();
for level in Level::ALL {
let want = bp.level_counts.get(&level).copied().unwrap_or(0);
let got = actual.get(&level).copied().unwrap_or(0);
if want != got {
out.push(format!(
"level {}: blueprint asks for {want}, the form has {got}",
level.code()
));
}
}
for (objective, needed) in &bp.objective_minimums {
let got = record
.items
.iter()
.filter(|p| p.learning_objectives.iter().any(|o| o == objective))
.count();
if got < *needed {
out.push(format!(
"objective `{objective}`: blueprint asks for {needed} item(s), the form has {got}"
));
}
}
out
}
/// The set of objectives an assessment covers.
///
/// # Arguments
///
/// * `record` - the assessment record.
///
/// # Returns
///
/// The objective ids, deduplicated.
pub fn covered_objectives(record: &AssessmentFile) -> BTreeSet<String> {
record
.items
.iter()
.flat_map(|p| p.learning_objectives.iter().cloned())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn form_labels_extend_past_z() {
assert_eq!(form_label(0), "A");
assert_eq!(form_label(1), "B");
assert_eq!(form_label(25), "Z");
assert_eq!(form_label(26), "AA");
assert_eq!(form_label(27), "AB");
}
#[test]
fn option_order_is_a_reproducible_permutation() {
let form = Form {
id: "A".into(),
seed: 12345,
shuffle_items: false,
shuffle_options: true,
};
let a = option_order(&form, "b::q-1", 5);
let b = option_order(&form, "b::q-1", 5);
assert_eq!(a, b, "same inputs give the same order");
let other = option_order(&form, "b::q-2", 5);
assert_ne!(a, other, "different items permute differently");
let mut sorted = a.clone();
sorted.sort_unstable();
assert_eq!(sorted, vec![0, 1, 2, 3, 4]);
}
#[test]
fn option_order_is_identity_when_shuffling_is_off() {
let form = Form {
id: "A".into(),
seed: 1,
shuffle_items: false,
shuffle_options: false,
};
assert_eq!(option_order(&form, "b::q-1", 4), vec![0, 1, 2, 3]);
}
#[test]
fn blueprint_check_reports_shortfalls() {
let record: AssessmentFile = serde_yaml_ng::from_str(
r#"
assessment: { id: x, title: X }
blueprint:
level_counts: { 1: 2, 3: 1 }
objective_minimums: { lo-key: 2 }
items:
- { number: 1, item: "b::q-1", level: 1, learning_objectives: [lo-key] }
- { number: 2, item: "b::q-2", level: 1 }
"#,
)
.unwrap();
let issues = check_blueprint(&record);
assert!(issues.iter().any(|i| i.contains("level 3")), "{issues:?}");
assert!(issues.iter().any(|i| i.contains("lo-key")), "{issues:?}");
// Level 1 matches, so it must not be reported.
assert!(!issues.iter().any(|i| i.contains("level 1")));
}
#[test]
fn covered_objectives_deduplicates() {
let record: AssessmentFile = serde_yaml_ng::from_str(
r#"
assessment: { id: x, title: X }
items:
- { number: 1, item: "b::q-1", learning_objectives: [lo-a, lo-b] }
- { number: 2, item: "b::q-2", learning_objectives: [lo-a] }
"#,
)
.unwrap();
let set = covered_objectives(&record);
assert_eq!(set.len(), 2);
assert!(set.contains("lo-a"));
}
}
+587
View File
@@ -0,0 +1,587 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! The command-line argument model.
//!
//! Everything here is `clap` derive input: the top-level [`Cli`], the [`Command`]
//! enum, one args struct or subcommand enum per command, and a handful of small
//! `ValueEnum`s that mirror a library enum so it can appear on the command line.
//!
//! Those mirror enums each carry an `as_*` method that converts the CLI-facing
//! value into the corresponding [`coursebank`] domain type. Keeping the conversion
//! next to the enum means the mapping is in one place and the command handlers in
//! [`crate::commands`] never match on a raw CLI enum.
//!
//! Fields are `pub(crate)` because the handlers read them directly; nothing here is
//! exported beyond the binary.
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand, ValueEnum};
use coursebank::assessment::{Kind as AssessmentKind, Platform};
use coursebank::catalog::Severity;
use coursebank::item::IrtModel;
use coursebank::store;
/// Manage course item banks, assessments, and the analysis that comes back.
#[derive(Debug, Parser)]
#[command(name = "coursebank", version, about, long_about = None)]
pub(crate) struct Cli {
/// Course directory, the one holding course.yaml.
#[arg(long, short = 'C', global = true, default_value = ".")]
pub(crate) course: PathBuf,
/// Print less.
#[arg(long, short, global = true)]
pub(crate) quiet: bool,
#[command(subcommand)]
pub(crate) command: Command,
}
/// The top-level command set. Each variant maps to one handler in
/// [`crate::commands`].
#[derive(Debug, Subcommand)]
pub(crate) enum Command {
/// Create a new course directory.
Init(InitArgs),
/// Write JSON Schemas so your editor can validate the YAML as you type.
Schema,
/// Check every file for problems that must be fixed.
Validate,
/// Check items against item-writing guidance.
Lint(LintArgs),
/// Summarize the item pool and objective coverage.
Catalog(CatalogArgs),
/// Work with item banks.
#[command(subcommand)]
Bank(BankCommand),
/// Work with assessment records.
#[command(subcommand)]
Assessment(AssessmentCommand),
/// Draw a new assessment from the pool.
Assemble(AssembleArgs),
/// Show which items have been used, and when.
#[command(subcommand)]
Usage(UsageCommand),
/// Produce a Canvas package, a printable exam, or Markdown.
#[command(subcommand)]
Export(ExportCommand),
/// Inspect, dump, and configure the Typst export templates.
#[command(subcommand)]
Template(TemplateCommand),
/// Read a grading export into the response store.
#[command(subcommand)]
Ingest(IngestCommand),
/// Compute statistics from stored responses.
#[command(subcommand)]
Analyze(AnalyzeCommand),
/// Write statistics back onto the items.
Calibrate(CalibrateArgs),
/// Write reports.
#[command(subcommand)]
Report(ReportCommand),
/// List what is in the response store.
Data,
}
#[derive(Debug, Args)]
pub(crate) struct InitArgs {
/// Course code, e.g. "BIOSC 1540".
#[arg(long)]
pub(crate) code: String,
/// Course title.
#[arg(long)]
pub(crate) title: String,
/// Term, e.g. 2026s.
#[arg(long)]
pub(crate) term: String,
/// Also write an example bank and assessment.
#[arg(long)]
pub(crate) with_examples: bool,
}
#[derive(Debug, Args)]
pub(crate) struct LintArgs {
/// List every rule and its code, then exit.
#[arg(long)]
pub(crate) list_rules: bool,
/// Only run these rule codes.
#[arg(long, value_delimiter = ',')]
pub(crate) only: Vec<String>,
/// Skip these rule codes.
#[arg(long, value_delimiter = ',')]
pub(crate) ignore: Vec<String>,
/// Only report findings at this severity or above.
#[arg(long, value_enum, default_value = "low")]
pub(crate) min_severity: SeverityArg,
/// Exit 0 even when findings exist.
#[arg(long)]
pub(crate) no_fail: bool,
}
/// CLI mirror of [`coursebank::catalog::Severity`].
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum SeverityArg {
Low,
Medium,
High,
}
impl SeverityArg {
/// Converts the CLI value into the library's [`Severity`].
pub(crate) fn as_severity(self) -> Severity {
match self {
SeverityArg::Low => Severity::Low,
SeverityArg::Medium => Severity::Medium,
SeverityArg::High => Severity::High,
}
}
}
#[derive(Debug, Args)]
pub(crate) struct CatalogArgs {
/// Show per-objective coverage and the gaps in it.
#[arg(long)]
pub(crate) coverage: bool,
/// Show topic counts.
#[arg(long)]
pub(crate) topics: bool,
}
#[derive(Debug, Subcommand)]
pub(crate) enum BankCommand {
/// Create an empty bank file.
New {
/// Bank id.
id: String,
/// Bank title.
#[arg(long)]
title: Option<String>,
},
/// List banks and their item counts.
List,
}
#[derive(Debug, Subcommand)]
pub(crate) enum AssessmentCommand {
/// Create an empty assessment record.
New {
/// Assessment id.
id: String,
/// Title.
#[arg(long)]
title: Option<String>,
/// Kind of assessment.
#[arg(long, value_enum, default_value = "exam")]
kind: KindArg,
},
/// List assessment records.
List,
/// Show one record in detail.
Show {
/// Assessment id.
id: String,
},
}
/// CLI mirror of [`coursebank::assessment::Kind`].
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum KindArg {
Exam,
Quiz,
Homework,
Practice,
Final,
}
impl KindArg {
/// Converts the CLI value into the library's [`AssessmentKind`].
pub(crate) fn as_kind(self) -> AssessmentKind {
match self {
KindArg::Exam => AssessmentKind::Exam,
KindArg::Quiz => AssessmentKind::Quiz,
KindArg::Homework => AssessmentKind::Homework,
KindArg::Practice => AssessmentKind::Practice,
KindArg::Final => AssessmentKind::Final,
}
}
}
#[derive(Debug, Args)]
pub(crate) struct AssembleArgs {
/// Assessment id to create.
pub(crate) id: String,
/// Title.
#[arg(long)]
pub(crate) title: Option<String>,
/// Kind of assessment.
#[arg(long, value_enum, default_value = "exam")]
pub(crate) kind: KindArg,
/// Administration date, YYYY-MM-DD. Defaults to today.
#[arg(long)]
pub(crate) date: Option<String>,
/// Where it will be given.
#[arg(long, value_enum, default_value = "paper")]
pub(crate) platform: PlatformArg,
/// How many items at each level, e.g. --levels 1=6,2=8,3=10,4=6.
#[arg(long, value_delimiter = ',')]
pub(crate) levels: Vec<String>,
/// Bonus items per level, same syntax.
#[arg(long, value_delimiter = ',')]
pub(crate) bonus: Vec<String>,
/// Minimum items per objective, e.g. --require lo-kinetics=2.
#[arg(long, value_delimiter = ',')]
pub(crate) require: Vec<String>,
/// Restrict the draw to these lectures.
#[arg(long, value_delimiter = ',')]
pub(crate) lectures: Vec<String>,
/// Restrict the draw to these topics.
#[arg(long, value_delimiter = ',')]
pub(crate) topics: Vec<String>,
/// Restrict the draw to these banks.
#[arg(long, value_delimiter = ',')]
pub(crate) banks: Vec<String>,
/// At most this many items from any one bank.
#[arg(long)]
pub(crate) max_per_bank: Option<usize>,
/// Avoid items used within this many days.
#[arg(long, default_value_t = 180)]
pub(crate) cooldown: i64,
/// Seed, for a reproducible draw.
#[arg(long, default_value_t = 0)]
pub(crate) seed: u64,
/// How many alternate forms to declare.
#[arg(long, default_value_t = 1)]
pub(crate) forms: usize,
/// Show the draw without writing the record.
#[arg(long)]
pub(crate) dry_run: bool,
/// Overwrite an existing record.
#[arg(long)]
pub(crate) force: bool,
}
/// CLI mirror of [`coursebank::assessment::Platform`].
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum PlatformArg {
Paper,
Canvas,
Other,
}
impl PlatformArg {
/// Converts the CLI value into the library's [`Platform`].
pub(crate) fn as_platform(self) -> Platform {
match self {
PlatformArg::Paper => Platform::Paper,
PlatformArg::Canvas => Platform::Canvas,
PlatformArg::Other => Platform::Other,
}
}
}
#[derive(Debug, Subcommand)]
pub(crate) enum UsageCommand {
/// Show when each item was used.
History {
/// Restrict to one item id.
item: Option<String>,
},
/// Show approved items that have never been used.
Unused,
}
#[derive(Debug, Subcommand)]
pub(crate) enum ExportCommand {
/// Build a Canvas-importable QTI 1.2 package.
Qti {
/// Assessment id.
id: String,
/// Which form.
#[arg(long, default_value = "A")]
form: String,
/// Output path; defaults to build/<id>-<form>.zip.
#[arg(long)]
out: Option<PathBuf>,
/// Leave per-option feedback out of the package.
#[arg(long)]
no_feedback: bool,
},
/// Render a printable exam, answer key, and answer sheet.
///
/// Each document is produced by injecting data into a Typst template rather
/// than being built from scratch, so the layout is yours to change. Run
/// `coursebank template dump` to get the defaults as editable files.
Typst {
/// Assessment id.
id: String,
/// Which form; repeat or pass "all".
#[arg(long, default_value = "A")]
form: String,
/// Output directory; defaults to build/.
#[arg(long)]
out: Option<PathBuf>,
/// Which documents to write; defaults to all three.
#[arg(long, value_name = "VARIANT")]
variant: Vec<String>,
/// Use this template file instead of the usual lookup. Only valid with a
/// single --variant, since one file cannot be three documents.
#[arg(long)]
template: Option<PathBuf>,
/// Also write the payload as JSON, for a template that reads it with
/// `json("...")` rather than taking an injected region.
#[arg(long)]
json: bool,
/// Print the payload and the resolved template path without writing.
#[arg(long)]
dry_run: bool,
},
/// Write the items as Markdown, for review.
Md {
/// Assessment id.
id: String,
/// Include the answer key and rationales.
#[arg(long)]
with_key: bool,
/// Output path; defaults to build/<id>.md.
#[arg(long)]
out: Option<PathBuf>,
},
}
#[derive(Debug, Subcommand)]
pub(crate) enum TemplateCommand {
/// Show which template each document would use, and why.
List {
/// Resolve as if exporting this assessment, which brings the
/// per-assessment template override into the lookup.
#[arg(long)]
assessment: Option<String>,
},
/// Write the built-in templates into templates/ so you can edit them.
Dump {
/// Which documents; defaults to all three.
#[arg(long, value_name = "VARIANT")]
variant: Vec<String>,
/// Destination directory; defaults to templates/.
#[arg(long)]
out: Option<PathBuf>,
/// Overwrite files that already exist.
#[arg(long)]
force: bool,
/// Print to stdout instead of writing files.
#[arg(long)]
stdout: bool,
},
/// Write or show the render configuration.
Config {
/// Print the fully resolved configuration for this document, after every
/// layer has been applied, instead of writing a starter file.
#[arg(long, value_name = "VARIANT")]
resolved: Option<String>,
/// Destination path; defaults to templates/typst.yaml.
#[arg(long)]
out: Option<PathBuf>,
/// Overwrite a config file that already exists.
#[arg(long)]
force: bool,
},
}
/// Flags shared by every `ingest` subcommand, flattened into each variant.
#[derive(Debug, Args)]
pub(crate) struct IngestCommon {
/// The assessment record these responses belong to.
#[arg(long)]
pub(crate) assessment: String,
/// Administration date, YYYY-MM-DD. Defaults to the record's date.
#[arg(long)]
pub(crate) date: Option<String>,
/// Which form, if forms were used.
#[arg(long)]
pub(crate) form: Option<String>,
/// Replace student identifiers with keyed pseudonyms.
#[arg(long)]
pub(crate) pseudonymize: bool,
/// File holding the HMAC salt. Keep it outside the repository.
#[arg(long)]
pub(crate) salt_file: Option<PathBuf>,
/// Storage format.
#[arg(long, value_enum)]
pub(crate) format: Option<FormatArg>,
/// Parse and report without writing to the store.
#[arg(long)]
pub(crate) dry_run: bool,
}
/// CLI mirror of [`coursebank::store::Format`].
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum FormatArg {
Parquet,
Csv,
}
impl FormatArg {
/// Converts the CLI value into the library's [`store::Format`].
pub(crate) fn as_format(self) -> store::Format {
match self {
FormatArg::Parquet => store::Format::Parquet,
FormatArg::Csv => store::Format::Csv,
}
}
}
#[derive(Debug, Subcommand)]
pub(crate) enum IngestCommand {
/// Read a directory of Gradescope per-question CSV exports.
Gradescope {
/// The directory holding 1.csv .. N.csv.
dir: PathBuf,
#[command(flatten)]
common: IngestCommon,
},
/// Read a Canvas "Student Analysis" CSV.
Canvas {
/// The CSV file.
file: PathBuf,
#[command(flatten)]
common: IngestCommon,
},
}
#[derive(Debug, Subcommand)]
pub(crate) enum AnalyzeCommand {
/// Classical item analysis.
Items {
/// Assessment id.
id: String,
/// Pool every stored administration of this assessment.
#[arg(long)]
pooled: bool,
},
/// Fit an IRT model.
Irt {
/// Assessment id.
id: String,
/// Which model.
#[arg(long, value_enum, default_value = "two-pl")]
model: ModelArg,
/// Estimate without priors. Expect divergence on a single class.
#[arg(long)]
no_priors: bool,
},
/// Per-student mastery and cohort patterns.
Students {
/// Assessment id.
id: String,
},
}
/// CLI mirror of [`coursebank::item::IrtModel`].
#[derive(Debug, Clone, Copy, ValueEnum)]
pub(crate) enum ModelArg {
Rasch,
TwoPl,
ThreePl,
}
impl ModelArg {
/// Converts the CLI value into the library's [`IrtModel`].
pub(crate) fn as_model(self) -> IrtModel {
match self {
ModelArg::Rasch => IrtModel::Rasch,
ModelArg::TwoPl => IrtModel::TwoPl,
ModelArg::ThreePl => IrtModel::ThreePl,
}
}
}
#[derive(Debug, Args)]
pub(crate) struct CalibrateArgs {
/// Write the changes. Without this, the diff is printed and nothing is saved.
#[arg(long)]
pub(crate) apply: bool,
/// Skip the IRT fit.
#[arg(long)]
pub(crate) no_irt: bool,
/// Include practice assessments in the pool.
#[arg(long)]
pub(crate) include_practice: bool,
/// Require at least this many pooled examinees before writing anything.
#[arg(long, default_value_t = 10)]
pub(crate) min_n: usize,
}
#[derive(Debug, Subcommand)]
pub(crate) enum ReportCommand {
/// One report per student.
Students {
/// Assessment id.
id: String,
/// Also write HTML.
#[arg(long)]
html: bool,
/// Output directory; defaults to reports/<id>/.
#[arg(long)]
out: Option<PathBuf>,
/// Include the IRT ability estimate.
#[arg(long)]
ability: bool,
/// Leave out the comparison to the class.
#[arg(long)]
no_comparison: bool,
},
/// The instructor's item analysis.
Cohort {
/// Assessment id.
id: String,
/// Also write HTML.
#[arg(long)]
html: bool,
/// Output path; defaults to reports/<id>-cohort.md.
#[arg(long)]
out: Option<PathBuf>,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_cli_parses_a_realistic_invocation() {
let cli = Cli::try_parse_from([
"coursebank",
"--course",
"/tmp/course",
"assemble",
"exam-4",
"--title",
"Exam 4",
"--levels",
"1=6,2=8,3=10",
"--require",
"lo-kinetics=2",
"--forms",
"2",
])
.unwrap();
match cli.command {
Command::Assemble(args) => {
assert_eq!(args.id, "exam-4");
assert_eq!(args.forms, 2);
assert_eq!(args.levels.len(), 3);
assert_eq!(args.require, vec!["lo-kinetics=2".to_string()]);
}
other => panic!("parsed as {other:?}"),
}
}
#[test]
fn the_cli_rejects_an_unknown_subcommand() {
assert!(Cli::try_parse_from(["coursebank", "frobnicate"]).is_err());
}
}
+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Command handlers, grouped by workflow stage.
//!
//! [`run`] is the single dispatch point: it matches the parsed [`Command`] and
//! calls the matching handler. The handlers themselves live in submodules that
//! follow the workflow described in the crate documentation:
//!
//! - [`project`] — set up and check a course: `init`, `schema`, `validate`,
//! `lint`, `catalog`.
//! - [`banks`] — manage items and build assessments: `bank`, `assessment`,
//! `assemble`, `usage`.
//! - [`export`] — turn an assessment into deliverables: `export`, `template`.
//! - [`analysis`] — the responses-to-report pipeline: `ingest`, `analyze`,
//! `calibrate`, `report`, `data`.
//!
//! Every handler returns [`Outcome`] so the dispatcher can distinguish "finished
//! cleanly" from "finished, but found problems worth a non-zero exit code".
pub(crate) mod analysis;
pub(crate) mod banks;
pub(crate) mod export;
pub(crate) mod project;
use coursebank::error::Result;
use crate::cli::{Cli, Command};
/// What a command concluded.
pub(crate) enum Outcome {
/// Nothing to report.
Ok,
/// Problems were found, which is not the same as the command failing.
Findings,
}
/// Dispatches a command.
///
/// # Arguments
///
/// * `cli` - the parsed arguments.
///
/// # Returns
///
/// Whether findings were reported.
///
/// # Errors
///
/// Propagates any failure from the underlying operation.
pub(crate) fn run(cli: &Cli) -> Result<Outcome> {
match &cli.command {
Command::Init(args) => project::init(cli, args),
Command::Schema => project::schema(cli),
Command::Validate => project::validate(cli),
Command::Lint(args) => project::lint(cli, args),
Command::Catalog(args) => project::catalog(cli, args),
Command::Bank(sub) => banks::bank(cli, sub),
Command::Assessment(sub) => banks::assessment(cli, sub),
Command::Assemble(args) => banks::assemble(cli, args),
Command::Usage(sub) => banks::usage(cli, sub),
Command::Export(sub) => export::export(cli, sub),
Command::Template(sub) => export::template(cli, sub),
Command::Ingest(sub) => analysis::ingest(cli, sub),
Command::Analyze(sub) => analysis::analyze(cli, sub),
Command::Calibrate(args) => analysis::calibrate(cli, args),
Command::Report(sub) => analysis::report(cli, sub),
Command::Data => analysis::data(cli),
}
}
+386
View File
@@ -0,0 +1,386 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! 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.
use coursebank::calibrate;
use coursebank::canvas;
use coursebank::classical::{self, Thresholds};
use coursebank::error::Result;
use coursebank::gradescope;
use coursebank::irt;
use coursebank::layout::Layout;
use coursebank::report;
use coursebank::store::{self, Store};
use coursebank::students;
use coursebank::yaml;
use crate::cli::{AnalyzeCommand, CalibrateArgs, Cli, IngestCommand, ReportCommand};
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.
///
/// Enriches the parsed responses against the record, optionally pseudonymizes the
/// identifiers, and — unless `--dry-run` — writes them in the chosen format.
pub(crate) fn ingest(cli: &Cli, sub: &IngestCommand) -> Result<Outcome> {
let catalog = load(cli)?;
let (common, mut set) = match sub {
IngestCommand::Gradescope { dir, common } => {
let record = load_record(&catalog, &common.assessment)?;
let ctx = context(&catalog, &record, common)?;
let import = gradescope::ingest_dir(dir, &ctx)?;
// Grading-time partial credit is an ambiguity signal worth surfacing
// right here, while the exam is fresh.
for question in &import.questions {
for (letter, value, note) in question.partial_credit() {
println!(
"! 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)
}
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 {}",
common.assessment, common.assessment
);
Ok(Outcome::Ok)
}
/// `analyze`: classical item analysis, an IRT fit, or a per-student summary.
///
/// `items` returns [`Outcome::Findings`] when there is a revise queue.
pub(crate) fn analyze(cli: &Cli, sub: &AnalyzeCommand) -> Result<Outcome> {
let catalog = load(cli)?;
let store = Store::open(catalog.layout.data())?;
match sub {
AnalyzeCommand::Items { id, pooled } => {
let record = load_record(&catalog, id)?;
let set = responses_for(&store, &catalog, &record, *pooled)?;
let analysis =
classical::analyze(&set, &Thresholds::default(), Some(&record), Some(&catalog));
for w in &analysis.warnings {
println!("! {w}\n");
}
println!("{}\n", analysis.reliability.interpretation());
println!(
"{:>3} {:>5} {:>6} {:>6} {:>6} FLAGS",
"Q", "p", "r", "D", "blank"
);
for item in &analysis.items {
println!(
"{:>3} {:>5.2} {:>6} {:>6} {:>5.0}% {}",
item.number,
item.p_value,
item.point_biserial
.map(|v| format!("{v:+.2}"))
.unwrap_or_else(|| "n/a".into()),
item.discrimination_index
.map(|v| format!("{v:+.2}"))
.unwrap_or_else(|| "-".into()),
item.blank_rate * 100.0,
item.flags
.iter()
.map(|f| f.as_str())
.collect::<Vec<_>>()
.join(" ")
);
}
let queue = analysis.revise_queue();
if !queue.is_empty() {
println!("\n{} item(s) to look at, worst first:", queue.len());
for item in queue.iter().take(10) {
println!(
" q{:<3} {}",
item.number,
item.notes.first().map(|s| s.as_str()).unwrap_or("")
);
}
return Ok(Outcome::Findings);
}
Ok(Outcome::Ok)
}
AnalyzeCommand::Irt {
id,
model,
no_priors,
} => {
let record = load_record(&catalog, id)?;
let set = responses_for(&store, &catalog, &record, false)?;
let mut opts = irt::Options {
model: model.as_model(),
..irt::Options::default()
};
opts.priors.enabled = !no_priors;
let fit = irt::fit(&set.matrix(false), &opts);
for w in &fit.warnings {
println!("! {w}\n");
}
println!(
"{} model, {} iteration(s), {}",
model.as_model().as_str(),
fit.iterations,
if fit.converged {
"converged"
} else {
"did NOT converge"
}
);
println!(
"measures most precisely near θ = {:+.1}\n",
fit.peak_information()
);
println!(
"{:>3} {:>6} {:>7} {:>7} {:>7} NOTES",
"Q", "a", "b", "SE(a)", "SE(b)"
);
for item in &fit.items {
println!(
"{:>3} {:>6.2} {:>+7.2} {:>7} {:>7} {}",
item.number,
item.a,
item.b,
item.se_a
.map(|v| format!("{v:.2}"))
.unwrap_or_else(|| "-".into()),
item.se_b
.map(|v| format!("{v:.2}"))
.unwrap_or_else(|| "-".into()),
item.notes.first().map(|s| s.as_str()).unwrap_or("")
);
}
let mut abilities = fit.abilities.clone();
abilities.sort_by(|a, b| {
b.theta
.partial_cmp(&a.theta)
.unwrap_or(std::cmp::Ordering::Equal)
});
println!(
"\nability range {:+.2} to {:+.2}",
abilities.last().map(|a| a.theta).unwrap_or(0.0),
abilities.first().map(|a| a.theta).unwrap_or(0.0)
);
Ok(Outcome::Ok)
}
AnalyzeCommand::Students { id } => {
let record = load_record(&catalog, id)?;
let set = responses_for(&store, &catalog, &record, false)?;
let cohort = students::summarize(&set, &catalog.course, Some(&catalog), None);
println!(
"{} student(s), mean {:.0}% (SD {:.1})\n",
cohort.students.len(),
cohort.mean_percent,
cohort.sd_percent
);
print!("{}", report::roster(&cohort));
if !cohort.class_gaps.is_empty() {
println!("\nObjectives the class did not meet:");
for (objective, rate) in &cohort.class_gaps {
println!(
" {:>4.0}% {}",
rate * 100.0,
catalog.course.objective_text(objective)
);
}
}
if !cohort.archetypes.is_empty() {
println!("\nPatterns:");
for a in &cohort.archetypes {
println!(" {:<40} {} student(s)", a.label, a.members.len());
}
}
Ok(Outcome::Ok)
}
}
}
/// `calibrate`: fold stored statistics back onto the items.
///
/// Prints the plan and stops unless `--apply` is given; refuses to write until at
/// least `--min-n` examinees are pooled.
pub(crate) fn calibrate(cli: &Cli, args: &CalibrateArgs) -> Result<Outcome> {
let catalog = load(cli)?;
let store = Store::open(catalog.layout.data())?;
let opts = calibrate::Options {
irt: !args.no_irt,
include_practice: args.include_practice,
minimum_n: args.min_n,
..calibrate::Options::default()
};
let plan = calibrate::plan(&catalog, &store, &opts)?;
print!("{}", plan.render());
if plan.is_empty() {
return Ok(Outcome::Ok);
}
if !args.apply {
println!(
"Nothing written. Re-run with --apply to write these {} change(s) into the bank \
files, then review the git diff.",
plan.changes.len()
);
return Ok(Outcome::Ok);
}
for path in calibrate::apply(&plan)? {
println!("updated {}", path.display());
}
println!("\nReview the diff before committing: git diff banks/");
Ok(Outcome::Ok)
}
/// `report`: write per-student reports or the instructor's cohort item analysis.
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,
} => {
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 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)?;
println!(
"wrote {} file(s) for {} student(s) in {}",
written.len(),
cohort.students.len(),
dir.display()
);
Ok(Outcome::Ok)
}
ReportCommand::Cohort { id, html, out } => {
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));
let markdown = report::cohort(&analysis, &cohort, &catalog, &record, Some(&fit));
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());
}
Ok(Outcome::Ok)
}
}
}
/// `data`: list every administration held in the response store.
pub(crate) fn data(cli: &Cli) -> Result<Outcome> {
let layout = Layout::new(&cli.course);
let store = Store::open(layout.data())?;
let summaries = store::summarize(&store)?;
if summaries.is_empty() {
println!("no stored responses in {}", store.dir.display());
return Ok(Outcome::Ok);
}
println!(
"{:<40} {:>7} {:>9} {:>7} FILE",
"ADMINISTRATION", "ROWS", "STUDENTS", "ITEMS"
);
for s in &summaries {
println!(
"{:<40} {:>7} {:>9} {:>7} {}",
truncate(&s.administration_id, 40),
s.rows,
s.students,
s.items,
s.path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default()
);
}
Ok(Outcome::Ok)
}
+263
View File
@@ -0,0 +1,263 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Managing items and building assessments from them.
//!
//! [`bank`] and [`assessment`] create and list the two record types. [`assemble`]
//! draws a new assessment from the pool against a blueprint, and [`usage`] reports
//! where items have already been used so a draw can avoid repeats.
use std::collections::BTreeMap;
use coursebank::assessment::{AssessmentFile, Blueprint};
use coursebank::bank::BankFile;
use coursebank::date::Date;
use coursebank::error::{Error, Result};
use coursebank::history::History;
use coursebank::layout::Layout;
use coursebank::select;
use coursebank::yaml;
use crate::cli::{AssembleArgs, AssessmentCommand, BankCommand, Cli, UsageCommand};
use crate::commands::Outcome;
use crate::helpers::{
load, load_record, parse_level_map, parse_string_map, print_record, truncate,
};
/// `bank`: create an empty bank, list banks with counts, or import legacy JSON.
pub(crate) fn bank(cli: &Cli, sub: &BankCommand) -> Result<Outcome> {
let layout = Layout::new(&cli.course);
match sub {
BankCommand::New { id, title } => {
let path = layout.banks().join(format!("{id}.yaml"));
if path.exists() {
return Err(Error::usage(format!("{} already exists", path.display())));
}
let bank = BankFile::skeleton(id, title.as_deref().unwrap_or(id));
yaml::write(&path, &bank)?;
println!("wrote {}", path.display());
Ok(Outcome::Ok)
}
BankCommand::List => {
let catalog = load(cli)?;
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
for entry in &catalog.entries {
*counts.entry(entry.bank.as_str()).or_insert(0) += 1;
}
for (id, meta) in &catalog.banks {
println!(
"{:<20} {:>4} item(s) {}",
id,
counts.get(id.as_str()).copied().unwrap_or(0),
meta.title
);
}
Ok(Outcome::Ok)
}
}
}
/// `assessment`: create an empty record, list records, or show one in detail.
///
/// `show` returns [`Outcome::Findings`] when the record has validation problems.
pub(crate) fn assessment(cli: &Cli, sub: &AssessmentCommand) -> Result<Outcome> {
let layout = Layout::new(&cli.course);
match sub {
AssessmentCommand::New { id, title, kind } => {
let path = layout.assessments().join(format!("{id}.yaml"));
if path.exists() {
return Err(Error::usage(format!("{} already exists", path.display())));
}
let record =
AssessmentFile::skeleton(id, title.as_deref().unwrap_or(id), kind.as_kind());
record.save(&path)?;
println!("wrote {}", path.display());
Ok(Outcome::Ok)
}
AssessmentCommand::List => {
let records = AssessmentFile::load_all(&layout.assessments())?;
let history = History::load(&layout.assessments())?;
let _ = &history;
if records.is_empty() {
println!("no assessment records yet");
return Ok(Outcome::Ok);
}
println!(
"{:<16} {:<10} {:<12} {:>6} {:>8}",
"ID", "KIND", "DATE", "ITEMS", "POINTS"
);
for record in &records {
println!(
"{:<16} {:<10} {:<12} {:>6} {:>8.1}",
record.assessment.id,
record.assessment.kind.as_str(),
record
.assessment
.date
.map(|d| d.to_string())
.unwrap_or_else(|| "-".into()),
record.items.len(),
record.total_points(1.0)
);
}
Ok(Outcome::Ok)
}
AssessmentCommand::Show { id } => {
let catalog = load(cli)?;
let record = load_record(&catalog, id)?;
print_record(&catalog, &record);
let issues = catalog.validate_record(&record);
if !issues.is_empty() {
println!("\n{} problem(s):", issues.len());
for issue in &issues {
println!(" - {issue}");
}
return Ok(Outcome::Findings);
}
Ok(Outcome::Ok)
}
}
}
/// `assemble`: draw a new assessment from the pool against a blueprint.
///
/// The blueprint is built from the `--levels`/`--bonus`/`--require` flags and the
/// various restrictions. Without `--dry-run` the resulting record is written; the
/// draw is reproducible from `--seed`.
pub(crate) fn assemble(cli: &Cli, args: &AssembleArgs) -> Result<Outcome> {
let catalog = load(cli)?;
let layout = &catalog.layout;
let path = layout.assessments().join(format!("{}.yaml", args.id));
if path.exists() && !args.force {
return Err(Error::usage(format!(
"{} already exists; pass --force to replace it. Replacing an administered \
assessment invalidates the responses already stored against it",
path.display()
)));
}
let date = match &args.date {
Some(s) => s.parse::<Date>()?,
None => Date::today(),
};
let blueprint = Blueprint {
level_counts: parse_level_map(&args.levels)?,
bonus_counts: parse_level_map(&args.bonus)?,
objective_minimums: parse_string_map(&args.require)?,
lectures: args.lectures.clone(),
topics: args.topics.clone(),
banks: args.banks.clone(),
max_per_bank: args.max_per_bank,
cooldown_days: Some(args.cooldown),
seed: Some(args.seed),
};
if blueprint.scored_total() == 0 {
return Err(Error::usage(
"no items requested; pass --levels, e.g. --levels 1=6,2=8,3=10,4=6".to_string(),
));
}
let history = History::load(&layout.assessments())?;
let selection = select::select(&catalog, &blueprint, &history, date)?;
let record = select::to_record(
&catalog,
&selection,
&args.id,
args.title.as_deref().unwrap_or(&args.id),
args.kind.as_kind(),
date,
args.platform.as_platform(),
&blueprint,
args.forms.max(1),
)?;
for note in &selection.notes {
println!("! {note}");
}
if !selection.notes.is_empty() {
println!();
}
print_record(&catalog, &record);
let drift = select::check_blueprint(&record);
if !drift.is_empty() {
println!("\nBlueprint not fully satisfied:");
for d in &drift {
println!(" - {d}");
}
}
if args.dry_run {
println!("\n(dry run, nothing written)");
return Ok(Outcome::Ok);
}
record.save(&path)?;
println!("\nwrote {}", path.display());
println!(
"Next: review the draw, then\n coursebank export typst {} --form A\n \
coursebank export qti {} --form A",
args.id, args.id
);
Ok(Outcome::Ok)
}
/// `usage`: report item use history, or the approved items never used.
pub(crate) fn usage(cli: &Cli, sub: &UsageCommand) -> Result<Outcome> {
let catalog = load(cli)?;
let history = History::load(&catalog.layout.assessments())?;
match sub {
UsageCommand::History { item } => {
let uids: Vec<String> = match item {
Some(id) => vec![catalog.resolve(id)?],
None => catalog.entries.iter().map(|e| e.uid.clone()).collect(),
};
println!("{:<34} {:>5} {:<12} WHERE", "ITEM", "USES", "LAST");
for uid in uids {
let uses = history.for_item(&uid);
if uses.is_empty() && item.is_none() {
continue;
}
let where_used: Vec<String> = uses
.iter()
.map(|u| format!("{}#{}", u.assessment, u.number))
.collect();
println!(
"{:<34} {:>5} {:<12} {}",
truncate(&uid, 34),
uses.len(),
history
.last_used(&uid)
.map(|d| d.to_string())
.unwrap_or_else(|| "-".into()),
where_used.join(", ")
);
}
Ok(Outcome::Ok)
}
UsageCommand::Unused => {
let mut unused: Vec<&str> = catalog
.assemblable()
.into_iter()
.filter(|e| history.use_count(&e.uid) == 0)
.map(|e| e.uid.as_str())
.collect();
unused.sort_unstable();
if unused.is_empty() {
println!("every approved item has been used at least once");
return Ok(Outcome::Ok);
}
println!("{} approved item(s) never used:", unused.len());
for uid in unused {
println!(" {uid}");
}
Ok(Outcome::Ok)
}
}
}
+342
View File
@@ -0,0 +1,342 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Turning an assembled assessment into deliverables.
//!
//! [`export`] writes the three output formats (a Canvas QTI package, rendered
//! Typst documents, and review Markdown). [`template`] inspects, dumps, and
//! configures the Typst templates those documents are injected into. Both share
//! [`pick_variants`], which resolves the `--variant` flags into a canonical list.
use coursebank::assessment::Form;
use coursebank::error::{Error, Result};
use coursebank::layout::Layout;
use coursebank::qti;
use coursebank::typst;
use coursebank::yaml;
use crate::cli::{Cli, ExportCommand, TemplateCommand};
use crate::commands::Outcome;
use crate::helpers::{load, load_record, markdown_export, pick_form};
/// `export`: build a QTI package, render Typst documents, or write Markdown.
pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
let catalog = load(cli)?;
let build = catalog.layout.build();
match sub {
ExportCommand::Qti {
id,
form,
out,
no_feedback,
} => {
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),
scoring_policy: record
.assessment
.scoring_policy
.unwrap_or(coursebank::assessment::ScoringPolicy::KeepHighest),
};
let package = qti::build(&catalog, &record, &opts)?;
let path = out
.clone()
.unwrap_or_else(|| build.join(format!("{id}-{}.zip", form.id)));
package.write_zip(&path)?;
println!("wrote {}", path.display());
println!("Import in Canvas: Settings -> Import Course Content -> QTI .zip file");
Ok(Outcome::Ok)
}
ExportCommand::Typst {
id,
form,
out,
variant,
template,
json,
dry_run,
} => {
let record = load_record(&catalog, id)?;
let dir = out.clone().unwrap_or(build);
let variants = pick_variants(variant)?;
if template.is_some() && variants.len() > 1 {
return Err(Error::usage(
"--template applies to one document, but more than one --variant was \
requested; pass --variant exam (or key, or answer-sheet) alongside it"
.to_string(),
));
}
let forms: Vec<Form> = if form == "all" {
if record.forms.is_empty() {
vec![typst::Options::default().form]
} else {
record.forms.clone()
}
} else {
vec![pick_form(&record, form)?]
};
// Read once, outside both loops: the config describes the course, not
// the form, and re-reading it per form would let a mid-run edit make
// form A and form B disagree.
let config_file = typst::load_config(&catalog.layout)?;
let mut warned = false;
let mut used_embedded = false;
for f in &forms {
for variant in &variants {
let opts = typst::Options {
form: f.clone(),
variant: *variant,
template: template.clone(),
config: config_file.resolve(*variant),
};
let rendered = typst::render(&catalog, &record, &opts)?;
used_embedded |= rendered.origin == typst::Origin::Embedded;
for warning in &rendered.warnings {
eprintln!("warning: {warning}");
warned = true;
}
let stem = format!("{id}-{}{}", f.id, variant.suffix());
if *dry_run {
println!(
"{}: {} question(s) from {} via {}",
stem,
rendered.payload.questions.len(),
rendered.origin,
rendered
.slots
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(" + ")
);
continue;
}
let path = dir.join(format!("{stem}.typ"));
yaml::write_text(&path, &rendered.text)?;
println!("wrote {} (from {})", path.display(), rendered.origin);
if *json {
let json_path = dir.join(format!("{stem}.json"));
yaml::write_text(&json_path, &rendered.payload.to_json()?)?;
println!("wrote {}", json_path.display());
}
}
}
if *dry_run {
return Ok(Outcome::Ok);
}
if !cli.quiet {
println!("\nCompile with: pixi run -e docs typst compile <file>.typ");
if used_embedded {
println!(
"Some of these used a built-in template. To take over the layout:\n \
coursebank template dump"
);
}
}
Ok(if warned {
Outcome::Findings
} else {
Outcome::Ok
})
}
ExportCommand::Md { id, with_key, out } => {
let record = load_record(&catalog, id)?;
let markdown = markdown_export(&catalog, &record, *with_key)?;
let path = out
.clone()
.unwrap_or_else(|| build.join(format!("{id}.md")));
yaml::write_text(&path, &markdown)?;
println!("wrote {}", path.display());
Ok(Outcome::Ok)
}
}
}
/// Resolves the `--variant` flags, defaulting to every document.
///
/// # Arguments
///
/// * `names` - the raw flag values, possibly empty.
///
/// # Returns
///
/// The variants, deduplicated and in canonical order so that
/// `--variant key --variant exam` still writes the paper first.
///
/// # Errors
///
/// 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());
}
let mut wanted = Vec::new();
for name in names {
let variant = typst::Variant::parse(name)?;
if !wanted.contains(&variant) {
wanted.push(variant);
}
}
// Canonical order, not the order they were typed.
Ok(typst::Variant::ALL
.into_iter()
.filter(|v| wanted.contains(v))
.collect())
}
/// `template`: list the template lookup, dump the built-ins to edit, or write and
/// inspect the render configuration.
pub(crate) fn template(cli: &Cli, sub: &TemplateCommand) -> Result<Outcome> {
let layout = Layout::new(&cli.course);
match sub {
TemplateCommand::List { assessment } => {
let id = assessment.as_deref();
println!("Templates are looked up in this order, first match wins:\n");
for variant in typst::Variant::ALL {
println!("{}", variant.as_str());
let mut resolved = false;
for path in typst::template::candidates(&layout, variant, id) {
let present = path.is_file();
let mark = if present && !resolved {
resolved = true;
"->"
} else {
" "
};
let state = if present { "" } else { " (absent)" };
println!(" {mark} {}{state}", path.display());
}
let mark = if resolved { " " } else { "->" };
println!(" {mark} built-in");
// Reporting the slots requires parsing, and a template with broken
// markers should be named here rather than at export time.
match typst::template::load(&layout, variant, id, None) {
Ok(template) => {
let slots: Vec<&str> =
template.slots().iter().map(|s| s.as_str()).collect();
if slots.is_empty() {
println!(" slots: none — this template injects nothing");
} else {
println!(" slots: {}", slots.join(", "));
}
}
Err(e) => println!(" unusable: {e}"),
}
println!();
}
let config = typst::config_path(&layout);
if config.is_file() {
println!("Config: {}", config.display());
} else {
println!(
"Config: none ({} is absent, so built-in defaults apply)",
config.display()
);
}
Ok(Outcome::Ok)
}
TemplateCommand::Dump {
variant,
out,
force,
stdout,
} => {
let variants = pick_variants(variant)?;
if *stdout {
for (index, v) in variants.iter().enumerate() {
if index > 0 {
println!();
}
if variants.len() > 1 {
println!("// ── {} ──", v.template_file());
}
print!("{}", typst::template::embedded(*v));
}
return Ok(Outcome::Ok);
}
let dir = out.clone().unwrap_or_else(|| layout.templates());
let (written, skipped) = typst::template::dump(&dir, &variants, *force)?;
for path in &written {
println!("wrote {}", path.display());
}
for path in &skipped {
println!(
"kept {} (already exists; --force to overwrite)",
path.display()
);
}
if !written.is_empty() && !cli.quiet {
println!(
"\nThese are yours to edit. Only the marked regions are replaced on \
export,\nso restyle freely:\n typst watch {}",
dir.join(typst::Variant::Exam.template_file()).display()
);
}
// Skipped files are worth an exit code: a script that expected to
// refresh them did not.
Ok(if skipped.is_empty() {
Outcome::Ok
} else {
Outcome::Findings
})
}
TemplateCommand::Config {
resolved,
out,
force,
} => {
if let Some(name) = resolved {
let variant = typst::Variant::parse(name)?;
let config_file = typst::load_config(&layout)?;
let config = config_file.resolve(variant);
println!(
"# Resolved configuration for `{}`, after every layer.",
variant.as_str()
);
print!("{}", config.to_yaml()?);
return Ok(Outcome::Ok);
}
let path = out.clone().unwrap_or_else(|| typst::config_path(&layout));
if path.exists() && !force {
return Err(Error::usage(format!(
"{} already exists; pass --force to overwrite it, or --resolved <variant> to \
see what it currently produces",
path.display()
)));
}
yaml::write_text(&path, typst::CONFIG_TEMPLATE)?;
println!("wrote {}", path.display());
Ok(Outcome::Ok)
}
}
}
+274
View File
@@ -0,0 +1,274 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Setting up a course and checking it stays well-formed.
//!
//! These are the commands you reach for before and around authoring: create the
//! directory (`init`), write editor schemas (`schema`), and run the two kinds of
//! 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 coursebank::assessment::AssessmentFile;
use coursebank::bank::BankFile;
use coursebank::course::{COURSE_FILE, CourseFile};
use coursebank::error::{Error, Result};
use coursebank::jsonschema;
use coursebank::layout::Layout;
use coursebank::lint::{self, Rule};
use coursebank::taxonomy::Level;
use coursebank::yaml;
use crate::cli::{CatalogArgs, Cli, InitArgs, LintArgs};
use crate::commands::Outcome;
use crate::helpers::{load, truncate};
/// The `.gitignore` written by `init`.
pub(crate) const GITIGNORE: &str = "\
# Generated output: exports, rendered exams, reports.
build/
reports/
# Typst and PDF artifacts.
*.pdf
# The pseudonymization salt must never be committed. Without it, hashed student
# ids can be reversed by brute force; with it in the repository, so can they.
*.salt
.coursebank-salt
# Editor and OS noise.
.DS_Store
*.swp
";
/// `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);
let course_path = layout.course_file();
if course_path.exists() {
return Err(Error::usage(format!(
"{} already exists; refusing to overwrite it",
course_path.display()
)));
}
layout.create_all()?;
let course = CourseFile::skeleton(&args.code, &args.title, &args.term);
course.save(&course_path)?;
println!("wrote {}", course_path.display());
let schemas = jsonschema::write_all(&layout.schema())?;
println!("wrote {} JSON Schema files", schemas.len());
if args.with_examples {
let bank = BankFile::skeleton("example", "Example bank");
let bank_path = layout.banks().join("example.yaml");
yaml::write(&bank_path, &bank)?;
println!("wrote {}", bank_path.display());
}
yaml::write_text(&cli.course.join(".gitignore"), GITIGNORE)?;
println!(
"\nNext: edit {} to add your learning objectives and lectures, then\n \
coursebank bank new unit-1 --title \"Unit 1\"\n coursebank validate",
COURSE_FILE
);
Ok(Outcome::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);
let written = jsonschema::write_all(&layout.schema())?;
for path in &written {
println!("wrote {}", path.display());
}
println!(
"\nAdd this to the top of a bank file so your editor validates as you type:\n {}",
jsonschema::Kind::Bank.modeline("../.coursebank/schema")
);
Ok(Outcome::Ok)
}
/// `validate`: check every bank and assessment record for hard problems.
///
/// Returns [`Outcome::Findings`] when anything is wrong so CI can fail on it.
pub(crate) fn validate(cli: &Cli) -> Result<Outcome> {
let catalog = load(cli)?;
let issues = catalog.validate()?;
let records = AssessmentFile::load_all(&catalog.layout.assessments())?;
let mut all = issues;
for record in &records {
for issue in catalog.validate_record(record) {
all.push(format!("{}: {issue}", record.assessment.id));
}
}
if all.is_empty() {
if !cli.quiet {
println!(
"{} item(s) in {} bank(s) and {} assessment record(s): no problems found",
catalog.entries.len(),
catalog.banks.len(),
records.len()
);
}
return Ok(Outcome::Ok);
}
println!("{} problem(s):\n", all.len());
for issue in &all {
println!(" - {issue}");
}
Ok(Outcome::Findings)
}
/// `lint`: run item-writing rules, grouped by subject, honoring the filter flags.
///
/// With `--list-rules` it prints the rule table and stops. Otherwise it returns
/// [`Outcome::Findings`] when anything fires, unless `--no-fail` was given.
pub(crate) fn lint(cli: &Cli, args: &LintArgs) -> Result<Outcome> {
if args.list_rules {
println!("{:<32} {:<8} WHAT IT CATCHES", "CODE", "SEVERITY");
for rule in Rule::ALL {
println!(
"{:<32} {:<8} {}",
rule.code(),
rule.severity().label(),
rule.description()
);
}
return Ok(Outcome::Ok);
}
let catalog = load(cli)?;
let thresholds = lint::Thresholds::default();
let mut findings = lint::lint_catalog(&catalog, &thresholds);
let minimum = args.min_severity.as_severity();
findings.retain(|f| f.severity >= minimum);
if !args.only.is_empty() {
findings.retain(|f| args.only.iter().any(|c| c == f.rule.code()));
}
if !args.ignore.is_empty() {
findings.retain(|f| !args.ignore.iter().any(|c| c == f.rule.code()));
}
if findings.is_empty() {
if !cli.quiet {
println!("no lint findings across {} item(s)", catalog.entries.len());
}
return Ok(Outcome::Ok);
}
let mut by_subject: BTreeMap<&str, Vec<&lint::Finding>> = BTreeMap::new();
for f in &findings {
by_subject.entry(f.subject.as_str()).or_default().push(f);
}
for (subject, items) in &by_subject {
println!("{subject}");
for f in items {
println!(
" [{}] {}{}",
f.severity.label(),
f.rule.code(),
f.message
);
}
println!();
}
let counts = lint::summarize(&findings);
println!(
"{} finding(s) across {} rule(s)",
findings.len(),
counts.len()
);
println!("Silence a rule with --ignore <code>, or see them all with --list-rules.");
if args.no_fail {
Ok(Outcome::Ok)
} else {
Ok(Outcome::Findings)
}
}
/// `catalog`: summarize the pool — counts by status, level, optionally topic, and
/// optionally per-objective coverage with its gaps.
pub(crate) fn catalog(cli: &Cli, args: &CatalogArgs) -> Result<Outcome> {
let catalog = load(cli)?;
println!(
"{}{} ({})",
catalog.course.course.code, catalog.course.course.title, catalog.course.course.term
);
println!(
"{} item(s) across {} bank(s); {} assemblable\n",
catalog.entries.len(),
catalog.banks.len(),
catalog.assemblable().len()
);
println!("By status:");
for (status, count) in catalog.status_counts() {
println!(" {:<16} {count}", status.as_str());
}
println!("\nBy level:");
for level in Level::ALL {
let count = catalog.level_counts().get(&level).copied().unwrap_or(0);
println!(" {} {:<12} {count}", level.code(), level.name());
}
if args.topics {
println!("\nBy topic:");
for (topic, count) in catalog.topics() {
println!(" {topic:<30} {count}");
}
}
if args.coverage {
let coverage = catalog.coverage();
println!("\nObjective coverage:");
println!(
" {:<40} {:>6} {:>6} MAX LEVEL",
"OBJECTIVE", "ITEMS", "READY"
);
for row in &coverage.rows {
println!(
" {:<40} {:>6} {:>6} {}",
truncate(&row.objective, 40),
row.total,
row.assemblable,
row.max_level
.map(|l| l.code().to_string())
.unwrap_or_else(|| "-".into())
);
}
if !coverage.gaps.is_empty() {
println!("\n{} gap(s):", coverage.gaps.len());
for gap in &coverage.gaps {
println!(" [{}] {}", gap.severity().label(), gap.message());
}
return Ok(Outcome::Findings);
}
}
Ok(Outcome::Ok)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_gitignore_protects_the_salt() {
assert!(GITIGNORE.contains(".coursebank-salt"));
assert!(GITIGNORE.contains("build/"));
}
}
+33
View File
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Getting grading data in, and keeping it.
//!
//! Every platform exports a different shape and none of them is analyzable, so
//! this module's job is to turn all of them into one long-format row per student
//! per item — see [`responses::Response`].
//!
//! ```text
//! gradescope ──┐
//! ├──▶ responses (canonical long form) ──▶ store ──▶ data/*.parquet
//! canvas ──────┘
//! ```
//!
//! [`gradescope`] and [`canvas`] are both written against real exports rather than
//! documentation, because the formats do things the documentation does not mention:
//! bonus questions carrying a maximum of zero, trailing rows shorter than the
//! header, rubric labels with the instructor's parenthetical attached, and answer
//! *text* where you wanted an option letter.
//!
//! [`store`] keeps one file per administration rather than one big table, so
//! re-ingesting one exam cannot corrupt another. [`store_parquet`] contains every
//! use of `arrow` and `parquet` in the entire crate, which is what makes
//! `--no-default-features` a one-file change rather than a refactor.
pub mod canvas;
pub mod gradescope;
pub mod responses;
pub mod store;
#[cfg(feature = "parquet")]
pub mod store_parquet;
+622
View File
@@ -0,0 +1,622 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Reading Canvas's "Student Analysis" quiz export.
//!
//! Canvas exports one wide CSV for the whole quiz. After the student columns come
//! two columns per question: one headed `<question id>: <question text>` holding
//! the answer text the student chose, and one immediately after it holding the
//! points earned, headed with the points possible.
//!
//! The awkward part is that Canvas records answer text, not option letters. So
//! recovering "this student chose C" means matching the exported text back against
//! the item's options. That matching is done on normalized text because the text
//! makes a round trip through the QTI export and Canvas's own HTML sanitizer, and
//! comes back with different markup than it left with.
//!
//! When a match cannot be made, the row keeps its score and loses its option
//! letter, with a warning naming the question. Totals and per-objective mastery
//! still work, and only distractor analysis is affected.
//!
//! Questions are aligned to item numbers by matching stem text the same way, with
//! column order as the fallback. Order alone would be wrong for any quiz where
//! Canvas shuffled the questions.
use std::collections::BTreeMap;
use std::path::Path;
use crate::assessment::AssessmentFile;
use crate::catalog::Catalog;
use crate::date::Date;
use crate::error::{Error, Result};
use crate::responses::{Response, ResponseSet, administration_id};
/// What the ingest needs that the export does not carry.
pub type Context = crate::gradescope::Context;
/// One question column pair found in the header.
#[derive(Debug, Clone)]
struct QuestionColumn {
/// Index of the answer-text column.
text_index: usize,
/// Index of the points-earned column, when the header had one.
points_index: Option<usize>,
/// The Canvas question id, from the column header.
///
/// Kept for diagnostics rather than for matching: when a column cannot be
/// traced to the assessment record, or an answer cannot be traced to an option,
/// this id is what lets you find the question in Canvas and see what differs.
/// Matching itself goes by question text, because Canvas ids change when a quiz
/// is copied to a new term.
canvas_id: String,
/// The question text from the header.
question_text: String,
/// Points possible, parsed from the points column's header.
points_possible: f64,
/// The item number this maps to, once resolved.
item_number: Option<u32>,
}
/// Splits a question column header into its id and text.
///
/// # Arguments
///
/// * `header` - the column header.
///
/// # Returns
///
/// The id and text, or `None` when the header is not a question column.
fn split_question_header(header: &str) -> Option<(String, String)> {
let (id, rest) = header.split_once(':')?;
let id = id.trim();
if id.is_empty() || !id.chars().all(|c| c.is_ascii_digit()) {
return None;
}
Some((id.to_string(), rest.trim().to_string()))
}
/// Normalizes text for comparison.
///
/// Strips HTML tags, decodes the handful of entities that survive a QTI round
/// trip, drops punctuation, lowercases, and collapses whitespace.
///
/// # Arguments
///
/// * `s` - the text.
///
/// # Returns
///
/// The normalized form.
pub fn normalize(s: &str) -> String {
// Strip tags.
let mut stripped = String::with_capacity(s.len());
let mut in_tag = false;
for ch in s.chars() {
match ch {
'<' => in_tag = true,
'>' => {
in_tag = false;
stripped.push(' ');
}
_ if in_tag => {}
_ => stripped.push(ch),
}
}
// Decode the entities that actually appear.
let decoded = stripped
.replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&#39;", "'")
.replace("&rarr;", "->")
.replace("&harr;", "<->")
.replace("&minus;", "-");
let mut out = String::with_capacity(decoded.len());
let mut last_space = true;
for ch in decoded.chars() {
let c = ch.to_ascii_lowercase();
if c.is_alphanumeric() {
out.push(c);
last_space = false;
} else if (c.is_whitespace() || c == '-' || c == '_') && !last_space {
out.push(' ');
last_space = true;
}
// Everything else — punctuation, entity leftovers — is dropped.
}
out.trim().to_string()
}
/// Reads a Canvas Student Analysis export.
///
/// # Arguments
///
/// * `path` - the CSV file.
/// * `ctx` - identifying information.
/// * `record` - the assessment record, used to align questions to item numbers.
/// * `catalog` - the loaded course, used to recover option letters from answer
/// text. Without it, scores are still ingested but letters are not.
///
/// # Returns
///
/// The normalized responses.
///
/// # Errors
///
/// Returns [`Error::Csv`] on a malformed file and [`Error::Invalid`] when no
/// question columns can be found.
pub fn ingest(
path: &Path,
ctx: &Context,
record: Option<&AssessmentFile>,
catalog: Option<&Catalog>,
) -> Result<ResponseSet> {
let mut reader = csv::ReaderBuilder::new()
.flexible(true)
.has_headers(true)
.from_path(path)
.map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
let header = reader
.headers()
.map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?
.clone();
let header: Vec<String> = header.iter().map(|h| h.trim().to_string()).collect();
let find = |name: &str| header.iter().position(|h| h.eq_ignore_ascii_case(name));
let name_col = find("name");
let id_col = find("id");
let sis_col = find("sis_id").or_else(|| find("sis id"));
let section_col = find("section");
let submitted_col = find("submitted");
let attempt_col = find("attempt");
// Locate the question column pairs.
let mut questions: Vec<QuestionColumn> = Vec::new();
for (i, h) in header.iter().enumerate() {
if let Some((canvas_id, question_text)) = split_question_header(h) {
// The next column holds points earned; its header is the points
// possible. Canvas writes it as a bare number.
let (points_index, points_possible) = match header.get(i + 1) {
Some(next) => match next.trim().parse::<f64>() {
Ok(p) => (Some(i + 1), p),
Err(_) => (None, 0.0),
},
None => (None, 0.0),
};
questions.push(QuestionColumn {
text_index: i,
points_index,
canvas_id,
question_text,
points_possible,
item_number: None,
});
}
}
if questions.is_empty() {
return Err(Error::Invalid(vec![format!(
"{} has no question columns; a Canvas Student Analysis export heads each question \
`<id>: <question text>`. Did you export the Item Analysis instead?",
path.display()
)]));
}
let mut warnings = Vec::new();
align_questions(&mut questions, record, catalog, &mut warnings);
// Build the answer-text lookup once per question: normalized option text to
// option letter.
let mut answer_lookup: BTreeMap<usize, BTreeMap<String, String>> = BTreeMap::new();
let mut item_meta: BTreeMap<usize, (Option<String>, Vec<String>)> = BTreeMap::new();
if let (Some(cat), Some(rec)) = (catalog, record) {
for (qi, q) in questions.iter().enumerate() {
let Some(number) = q.item_number else {
continue;
};
let Some(placement) = rec.placement(number) else {
continue;
};
let Some(entry) = cat.get(&placement.item) else {
continue;
};
let mut map = BTreeMap::new();
for opt in &entry.item.options {
map.insert(normalize(&opt.text), opt.id.clone());
}
answer_lookup.insert(qi, map);
item_meta.insert(qi, (Some(placement.item.clone()), placement.key.clone()));
}
}
let admin = administration_id(&ctx.course, &ctx.term, &ctx.assessment_id);
let mut set = ResponseSet::new();
let mut unmatched_answers: BTreeMap<(u32, String), usize> = BTreeMap::new();
for rec in reader.records() {
let rec = rec.map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
let cell = |i: Option<usize>| -> Option<String> {
i.and_then(|i| rec.get(i))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
};
let sid = cell(sis_col).or_else(|| cell(id_col));
let name = cell(name_col);
if sid.is_none() && name.is_none() {
continue;
}
// Canvas emits a "Test Student" row for anyone who previewed the quiz.
if name.as_deref() == Some("Test Student") {
continue;
}
let student_key = sid
.clone()
.or_else(|| name.clone())
.unwrap_or_else(|| "unknown".to_string());
let section = cell(section_col);
let submitted = cell(submitted_col);
let attempt = cell(attempt_col).and_then(|a| a.parse::<u32>().ok());
// Only the graded attempt is exported per row, but a student who never
// submitted still gets a row; those carry no responses.
if submitted.is_none() && attempt.is_none() {
continue;
}
for (qi, q) in questions.iter().enumerate() {
let Some(number) = q.item_number else {
continue;
};
let answer_text = rec.get(q.text_index).map(|s| s.trim()).unwrap_or("");
let score = q
.points_index
.and_then(|i| rec.get(i))
.and_then(|s| s.trim().parse::<f64>().ok())
.unwrap_or(0.0);
let points = q.points_possible;
let credit = if points > 0.0 { score / points } else { 0.0 };
let mut selected = Vec::new();
if !answer_text.is_empty() {
let normalized = normalize(answer_text);
match answer_lookup.get(&qi).and_then(|m| m.get(&normalized)) {
Some(letter) => selected.push(letter.clone()),
None => {
*unmatched_answers
.entry((number, q.canvas_id.clone()))
.or_insert(0) += 1;
}
}
}
let (item_ref, key) = item_meta.get(&qi).cloned().unwrap_or((None, Vec::new()));
let correct = if answer_text.is_empty() {
None
} else if points > 0.0 {
Some(credit >= 0.999)
} else if !key.is_empty() && !selected.is_empty() {
Some(selected == key)
} else {
None
};
set.rows.push(Response {
administration_id: admin.clone(),
course: ctx.course.clone(),
term: ctx.term.clone(),
assessment_id: ctx.assessment_id.clone(),
date: ctx.date,
form: ctx.form.clone(),
student_key: student_key.clone(),
sid: sid.clone(),
name: name.clone(),
email: None,
section: section.clone(),
item_number: number,
item_ref,
item_version: None,
selected,
eliminated: Vec::new(),
correct,
credit,
points_possible: points,
score,
response_time_seconds: None,
level: None,
learning_objectives: Vec::new(),
topics: Vec::new(),
bonus: false,
dropped: false,
});
}
}
for ((number, canvas_id), count) in unmatched_answers {
warnings.push(format!(
"question {number} (Canvas id {canvas_id}): {count} answer(s) did not match any option \
text, so those responses have a score but no option letter; distractor analysis for \
this item will be incomplete. The usual cause is the option text being edited in \
Canvas after import"
));
}
if set.rows.is_empty() {
warnings.push(
"no student rows were found; the export may contain only the header, or every row \
may be an unsubmitted attempt"
.to_string(),
);
}
set.warnings.extend(warnings);
Ok(set)
}
/// Assigns an item number to each question column.
///
/// Matching on stem text is preferred over column order because Canvas shuffles
/// questions when the quiz says to, and the export follows the shuffled order.
///
/// # Arguments
///
/// * `questions` - the columns, updated in place.
/// * `record` - the assessment record.
/// * `catalog` - the loaded course.
/// * `warnings` - collects anything ambiguous.
fn align_questions(
questions: &mut [QuestionColumn],
record: Option<&AssessmentFile>,
catalog: Option<&Catalog>,
warnings: &mut Vec<String>,
) {
let Some(rec) = record else {
// Without a record, the only sensible assumption is column order.
for (i, q) in questions.iter_mut().enumerate() {
q.item_number = Some(i as u32 + 1);
}
warnings.push(
"no assessment record was supplied, so questions were matched to item numbers by \
column order; pass --assessment to match on question text instead"
.to_string(),
);
return;
};
// Normalized stem to item number, when the catalog is available.
let mut by_stem: BTreeMap<String, Vec<u32>> = BTreeMap::new();
if let Some(cat) = catalog {
for p in &rec.items {
if let Some(entry) = cat.get(&p.item) {
by_stem
.entry(normalize(&entry.item.stem))
.or_default()
.push(p.number);
}
}
}
let mut used: Vec<u32> = Vec::new();
let mut unmatched: Vec<String> = Vec::new();
for (i, q) in questions.iter_mut().enumerate() {
let normalized = normalize(&q.question_text);
let candidates = by_stem.get(&normalized);
match candidates {
Some(numbers) => {
// Prefer a number not already claimed, so two items sharing a stem
// do not collapse onto one.
match numbers.iter().find(|n| !used.contains(n)) {
Some(n) => {
q.item_number = Some(*n);
used.push(*n);
}
None => {
q.item_number = Some(numbers[0]);
}
}
}
None => {
// Fall back to position, which is right for an unshuffled quiz.
let fallback = rec.items.get(i).map(|p| p.number).unwrap_or(i as u32 + 1);
if !used.contains(&fallback) {
used.push(fallback);
}
q.item_number = Some(fallback);
// Report the Canvas question id, not just a count. The id is what
// you can search for in Canvas to see which question this was, and
// an unmatched column usually means the text was edited there after
// import — so being able to find it is the whole remedy.
unmatched.push(format!("{} (assumed question {fallback})", q.canvas_id));
}
}
}
if !unmatched.is_empty() {
warnings.push(format!(
"{} of {} question column(s) could not be matched to the assessment record by question \
text and were matched by column order instead. Canvas question id(s): {}. Check that \
the record describes this quiz, and that the question text was not edited in Canvas \
after import",
unmatched.len(),
questions.len(),
unmatched.join(", ")
));
}
}
/// Guesses a date from a Canvas timestamp column.
///
/// # Arguments
///
/// * `s` - the timestamp, e.g. `2026-04-01 14:03:22 UTC`.
///
/// # Returns
///
/// The date, when the leading token parses as one.
pub fn date_from_timestamp(s: &str) -> Option<Date> {
s.split_whitespace().next()?.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognizes_question_headers() {
assert_eq!(
split_question_header("123456: Which enzyme catalyzes the step?"),
Some((
"123456".to_string(),
"Which enzyme catalyzes the step?".to_string()
))
);
assert_eq!(split_question_header("name"), None);
assert_eq!(split_question_header("n correct"), None);
// A colon in prose is not a question column.
assert_eq!(split_question_header("Note: read carefully"), None);
}
#[test]
fn normalization_survives_a_qti_round_trip() {
// Option text authored as `K#sub[m] increases` is exported as HTML and
// comes back from Canvas wrapped in tags. Both must normalize to the same
// thing as the HTML we sent, or letters cannot be recovered.
let sent = "K<sub>m</sub> increases";
let returned = "<p>K<sub>m</sub> increases</p>";
assert_eq!(normalize(sent), normalize(returned));
assert_eq!(normalize(returned), "k m increases");
assert_eq!(normalize("K&nbsp;m increases"), "k m increases");
}
#[test]
fn normalization_ignores_punctuation_and_case() {
assert_eq!(
normalize("The Rate, Increases!"),
normalize("the rate increases")
);
assert_eq!(normalize(" spaced out "), "spaced out");
assert_eq!(normalize("<b>bold</b> text"), "bold text");
}
#[test]
fn timestamps_yield_dates() {
assert_eq!(
date_from_timestamp("2026-04-01 14:03:22 UTC").map(|d| d.to_string()),
Some("2026-04-01".to_string())
);
assert_eq!(date_from_timestamp("not a date"), None);
}
#[test]
fn falls_back_to_column_order_without_a_record() {
let mut questions = vec![
QuestionColumn {
text_index: 8,
points_index: Some(9),
canvas_id: "1".into(),
question_text: "Q one".into(),
points_possible: 1.0,
item_number: None,
},
QuestionColumn {
text_index: 10,
points_index: Some(11),
canvas_id: "2".into(),
question_text: "Q two".into(),
points_possible: 1.0,
item_number: None,
},
];
let mut warnings = Vec::new();
align_questions(&mut questions, None, None, &mut warnings);
assert_eq!(questions[0].item_number, Some(1));
assert_eq!(questions[1].item_number, Some(2));
assert_eq!(warnings.len(), 1);
}
#[test]
fn parses_a_realistic_export() {
let dir = std::env::temp_dir().join(format!("cb-canvas-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("analysis.csv");
std::fs::write(
&path,
"name,id,sis_id,section,submitted,attempt,\
1001: Which enzyme?,1.0,1002: Which pathway?,1.0,n correct,n incorrect,score\n\
Ada Lovelace,9001,1234567,L01,2026-04-01 14:00:00 UTC,1,Hexokinase,1.0,Glycolysis,\
1.0,2,0,2.0\n\
Alan Turing,9002,7654321,L01,2026-04-01 14:05:00 UTC,1,Pyruvate kinase,0.0,\
Glycolysis,1.0,1,1,1.0\n\
Test Student,9999,,L01,2026-04-01 13:00:00 UTC,1,Hexokinase,1.0,Glycolysis,1.0,2,0,\
2.0\n",
)
.unwrap();
let ctx = Context {
course: "BIOSC1540".into(),
term: "2026s".into(),
assessment_id: "quiz-1".into(),
date: None,
form: None,
};
let set = ingest(&path, &ctx, None, None).unwrap();
// Two real students, two questions each. The preview row is discarded.
assert_eq!(set.rows.len(), 4, "{:?}", set.warnings);
assert!(
set.rows
.iter()
.all(|r| r.name.as_deref() != Some("Test Student"))
);
assert_eq!(set.scored_total("1234567"), 2.0);
assert_eq!(set.scored_total("7654321"), 1.0);
let alan_q1 = set
.rows
.iter()
.find(|r| r.student_key == "7654321" && r.item_number == 1)
.unwrap();
assert_eq!(alan_q1.correct, Some(false));
assert_eq!(alan_q1.credit, 0.0);
// No catalog was supplied, so no letter could be recovered.
assert!(alan_q1.selected.is_empty());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn rejects_the_wrong_export_kind() {
let dir = std::env::temp_dir().join(format!("cb-canvas-bad-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("item.csv");
std::fs::write(&path, "question,discrimination index\nQ1,0.4\n").unwrap();
let ctx = Context {
course: "C".into(),
term: "T".into(),
assessment_id: "a".into(),
date: None,
form: None,
};
let err = ingest(&path, &ctx, None, None).unwrap_err();
assert!(err.to_string().contains("Student Analysis"));
std::fs::remove_dir_all(&dir).ok();
}
}
+859
View File
@@ -0,0 +1,859 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Reading Gradescope's per-question CSV exports.
//!
//! Gradescope exports one file per question, named `1.csv` through `N.csv`, in
//! wide form. Every assumption encoded here was checked against a real 32-question,
//! 24-student export rather than inferred from documentation, because several of
//! them are not what the format suggests.
//!
//! The layout is:
//!
//! ```text
//! Assignment Submission ID, Question Submission ID, First Name, Last Name,
//! SID, Email, Sections, Score, Submission Time, <rubric columns...>,
//! Adjustment, Comments, Grader, Tags
//! ```
//!
//! The rubric columns are the header slice strictly between `Submission Time`
//! and `Adjustment`. Student cells in those columns are the literal strings
//! `true` and `false`.
//!
//! Four things about real exports that a naive parser gets wrong:
//!
//! *Trailing rows are not aligned with the header.* After the student rows come
//! `Point Values`, `Rubric Numbers`, and `Scoring Method` rows with a label, some
//! empty cells, and then the numbers. The CSV reader must be in flexible mode or
//! it errors on the whole file.
//!
//! *Rubric labels are not always bare option letters.* Real ones include
//! `Selected C`, `Eliminated A`, and — this is the interesting case — a letter
//! followed by an instructor's parenthetical, such as
//! `B (Technically speaking, this answer describes HBD/HBA, but I can see how
//! this distractor is poorly written.)`. Those columns carry partial credit.
//!
//! *Partial credit awarded to a distractor is evidence, not noise.* When you gave
//! 1.0 of 1.5 points for choosing B, you decided at grading time that B was
//! partly defensible. That is exactly the ambiguity signal item analysis is
//! trying to detect, so it is captured as [`Flag::Ambiguous`] rather than
//! rounded away.
//!
//! *Bonus questions have `max_points` of zero* while a rubric column still
//! carries points. Key detection therefore uses the largest value across the
//! maximum *and* every column, not the maximum alone.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use crate::date::Date;
use crate::error::{Error, Result};
use crate::responses::{Response, ResponseSet, administration_id};
use crate::taxonomy::Flag;
/// What a rubric column represents.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColumnKind {
/// The student selected this option.
Select,
/// The student eliminated this option, under elimination scoring.
Eliminate,
/// Something else: a catch-all rubric row such as
/// `Incorrect selection with no eliminations`.
Other,
}
/// One rubric column.
#[derive(Debug, Clone)]
pub struct RubricColumn {
/// The header label, verbatim.
pub label: String,
/// What the column means.
pub kind: ColumnKind,
/// The option letter, when the label named one.
pub letter: Option<String>,
/// The instructor's parenthetical annotation, when there was one. Worth
/// keeping: it is usually the instructor explaining why an item is flawed.
pub note: Option<String>,
/// Points this column awards, from the `Point Values` row.
pub value: Option<f64>,
}
/// One student's row in a question file.
#[derive(Debug, Clone)]
pub struct StudentRow {
/// The institutional student id.
pub sid: Option<String>,
/// First and last name joined.
pub name: Option<String>,
/// The email.
pub email: Option<String>,
/// Section or lab.
pub section: Option<String>,
/// Points awarded, authoritative.
pub score: f64,
/// The submission timestamp, verbatim.
pub submission_time: Option<String>,
/// Indices of rubric columns marked `true`.
pub marks: Vec<usize>,
}
/// One parsed question file.
#[derive(Debug, Clone)]
pub struct Question {
/// The question number, from the file name.
pub number: u32,
/// Where it came from.
pub path: PathBuf,
/// The rubric columns, in header order.
pub columns: Vec<RubricColumn>,
/// The maximum points from the `Point Values` row.
pub max_points: f64,
/// The scoring method, when stated.
pub scoring_method: Option<String>,
/// The student rows.
pub rows: Vec<StudentRow>,
}
impl Question {
/// The largest point value anywhere in the question.
///
/// Bonus questions record a maximum of zero while still awarding points, so
/// the key has to be found by looking at the columns too.
///
/// # Returns
///
/// The largest value seen.
pub fn top_value(&self) -> f64 {
let mut top = self.max_points;
for c in &self.columns {
if let Some(v) = c.value {
if v > top {
top = v;
}
}
}
top
}
/// The option letters that earn full credit.
///
/// # Returns
///
/// The keyed letters, sorted. Empty when the point values were absent.
pub fn keyed(&self) -> Vec<String> {
let top = self.top_value();
if top <= 0.0 {
return Vec::new();
}
let mut out: BTreeSet<String> = BTreeSet::new();
for c in &self.columns {
if c.kind == ColumnKind::Select {
if let (Some(letter), Some(v)) = (&c.letter, c.value) {
if (v - top).abs() < 1e-9 {
out.insert(letter.clone());
}
}
}
}
out.into_iter().collect()
}
/// Distractors that were awarded partial credit at grading time.
///
/// # Returns
///
/// Letter, points awarded, and the instructor's note if any.
pub fn partial_credit(&self) -> Vec<(String, f64, Option<String>)> {
let top = self.top_value();
let mut out = Vec::new();
for c in &self.columns {
if c.kind != ColumnKind::Select {
continue;
}
if let (Some(letter), Some(v)) = (&c.letter, c.value) {
if v > 0.0 && v < top - 1e-9 {
out.push((letter.clone(), v, c.note.clone()));
}
}
}
out
}
/// Whether this looks like a bonus question.
///
/// # Returns
///
/// `true` when the maximum is zero but a column still awards points.
pub fn looks_like_bonus(&self) -> bool {
self.max_points <= 0.0 && self.top_value() > 0.0
}
/// Whether elimination scoring was in use.
pub fn uses_elimination(&self) -> bool {
self.columns.iter().any(|c| c.kind == ColumnKind::Eliminate)
}
/// Points the question was worth.
///
/// # Returns
///
/// The stated maximum, falling back to the largest column value for bonus
/// questions so that credit is still a meaningful fraction.
pub fn points_possible(&self) -> f64 {
if self.max_points > 0.0 {
self.max_points
} else {
self.top_value()
}
}
/// Flags implied by how the question was graded.
///
/// # Returns
///
/// [`Flag::Ambiguous`] when a distractor received partial credit.
pub fn implied_flags(&self) -> Vec<Flag> {
if self.partial_credit().is_empty() {
Vec::new()
} else {
vec![Flag::Ambiguous]
}
}
}
/// What the ingest needs to know that the export does not say.
#[derive(Debug, Clone)]
pub struct Context {
/// The course code.
pub course: String,
/// The term.
pub term: String,
/// The assessment id.
pub assessment_id: String,
/// The administration date.
pub date: Option<Date>,
/// The form, when forms were used.
pub form: Option<String>,
}
/// The result of reading a directory of question files.
#[derive(Debug, Clone)]
pub struct Import {
/// The normalized responses.
pub responses: ResponseSet,
/// The parsed question files, kept so grading-time decisions can be folded
/// into item calibration.
pub questions: Vec<Question>,
}
/// Classifies a rubric column label.
///
/// # Arguments
///
/// * `label` - the header text.
///
/// # Returns
///
/// The kind, the option letter if the label named one, and any trailing
/// annotation with its surrounding punctuation trimmed.
pub fn classify(label: &str) -> (ColumnKind, Option<String>, Option<String>) {
let trimmed = label.trim();
let lower = trimmed.to_ascii_lowercase();
let (kind, rest) = if let Some(r) = lower.strip_prefix("selected ") {
(
ColumnKind::Select,
trimmed[trimmed.len() - r.len()..].trim(),
)
} else if let Some(r) = lower.strip_prefix("eliminated ") {
(
ColumnKind::Eliminate,
trimmed[trimmed.len() - r.len()..].trim(),
)
} else {
(ColumnKind::Select, trimmed)
};
let mut chars = rest.chars();
let Some(first) = chars.next() else {
return (ColumnKind::Other, None, Some(label.to_string()));
};
let upper = first.to_ascii_uppercase();
let tail = chars.as_str();
// A single letter A-H, optionally followed by an annotation that does not
// begin with another alphanumeric. `B (poorly written)` is option B;
// `Both A and C are wrong` is not.
let letter_like = upper.is_ascii_uppercase()
&& ('A'..='H').contains(&upper)
&& tail
.chars()
.next()
.map(|c| !c.is_alphanumeric())
.unwrap_or(true);
if !letter_like {
return (ColumnKind::Other, None, Some(label.to_string()));
}
let note = tail.trim().trim_matches(|c: char| "().,;: ".contains(c));
let note = if note.is_empty() {
None
} else {
Some(note.to_string())
};
(kind, Some(upper.to_string()), note)
}
/// Parses one question file.
///
/// # Arguments
///
/// * `path` - the CSV file.
///
/// # Returns
///
/// The parsed question.
///
/// # Errors
///
/// Returns [`Error::Csv`] on a malformed file and [`Error::Invalid`] when the
/// header lacks the `Submission Time` column that delimits the rubric.
pub fn parse_question(path: &Path) -> Result<Question> {
let number = path
.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| s.parse::<u32>().ok())
.ok_or_else(|| {
Error::Invalid(vec![format!(
"{} is not named like a Gradescope question export (expected `12.csv`)",
path.display()
)])
})?;
// Flexible mode is required: the trailing `Point Values` rows are shorter
// than the header.
let mut reader = csv::ReaderBuilder::new()
.flexible(true)
.has_headers(false)
.from_path(path)
.map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
let mut records = reader.records();
let header = match records.next() {
Some(r) => r.map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?,
None => return Err(Error::Invalid(vec![format!("{} is empty", path.display())])),
};
let header: Vec<String> = header.iter().map(|s| s.trim().to_string()).collect();
let index_of = |name: &str| header.iter().position(|h| h == name);
let start = match index_of("Submission Time") {
Some(i) => i + 1,
None => {
return Err(Error::Invalid(vec![format!(
"{} has no `Submission Time` column, so the rubric columns cannot be located; \
is this a Gradescope per-question export?",
path.display()
)]));
}
};
let end = index_of("Adjustment").unwrap_or(header.len());
if end < start {
return Err(Error::Invalid(vec![format!(
"{} has `Adjustment` before `Submission Time`",
path.display()
)]));
}
let mut columns: Vec<RubricColumn> = header[start..end]
.iter()
.map(|label| {
let (kind, letter, note) = classify(label);
RubricColumn {
label: label.clone(),
kind,
letter,
note,
value: None,
}
})
.collect();
let sid_col = index_of("SID");
let first_col = index_of("First Name");
let last_col = index_of("Last Name");
let email_col = index_of("Email");
let section_col = index_of("Sections");
let score_col = index_of("Score");
let time_col = index_of("Submission Time");
let mut rows = Vec::new();
let mut max_points = 0.0f64;
let mut point_values: Vec<f64> = Vec::new();
let mut scoring_method = None;
for rec in records {
let rec = rec.map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
let cell = |i: Option<usize>| -> Option<String> {
i.and_then(|i| rec.get(i))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
};
if rec.iter().all(|c| c.trim().is_empty()) {
continue;
}
let label = rec.get(0).unwrap_or("").trim();
match label {
"Point Values" => {
// Label, then some empty cells, then the maximum, then one value
// per rubric column. Alignment was verified across every file in
// a real export, but collecting the non-empty numbers in order is
// robust to a leading blank moving.
let nums: Vec<f64> = rec
.iter()
.skip(1)
.filter(|c| !c.trim().is_empty())
.filter_map(|c| c.trim().parse::<f64>().ok())
.collect();
if let Some((first, rest)) = nums.split_first() {
max_points = *first;
point_values = rest.to_vec();
}
continue;
}
"Rubric Numbers" => continue,
"Scoring Method" => {
scoring_method = cell(Some(1));
continue;
}
_ => {}
}
// A student row must have a score cell that parses; anything else is a
// trailing annotation row we have not seen before, and skipping it is
// safer than failing the import.
let score = match cell(score_col).and_then(|s| s.parse::<f64>().ok()) {
Some(s) => s,
None => {
if cell(sid_col).is_none() && cell(email_col).is_none() {
continue;
}
0.0
}
};
let marks: Vec<usize> = (0..columns.len())
.filter(|i| {
rec.get(start + i)
.map(|c| c.trim().eq_ignore_ascii_case("true"))
.unwrap_or(false)
})
.collect();
let name = match (cell(first_col), cell(last_col)) {
(Some(f), Some(l)) => Some(format!("{f} {l}")),
(Some(f), None) => Some(f),
(None, Some(l)) => Some(l),
(None, None) => None,
};
rows.push(StudentRow {
sid: cell(sid_col),
name,
email: cell(email_col),
section: cell(section_col),
score,
submission_time: cell(time_col),
marks,
});
}
// Attach point values when they line up; when they do not, leave them off
// rather than misattribute credit to the wrong option.
if point_values.len() == columns.len() {
for (c, v) in columns.iter_mut().zip(point_values.iter()) {
c.value = Some(*v);
}
}
Ok(Question {
number,
path: path.to_path_buf(),
columns,
max_points,
scoring_method,
rows,
})
}
/// Reads every question file in a directory.
///
/// # Arguments
///
/// * `dir` - the directory of `N.csv` files.
/// * `ctx` - identifying information the export does not carry.
///
/// # Returns
///
/// Normalized responses and the parsed questions.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory cannot be read and [`Error::Invalid`]
/// when it holds no question files.
pub fn ingest_dir(dir: &Path, ctx: &Context) -> Result<Import> {
let mut paths: Vec<PathBuf> = std::fs::read_dir(dir)
.map_err(|e| Error::io(dir, e))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.extension().and_then(|e| e.to_str()) == Some("csv")
&& p.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or(false)
})
.collect();
if paths.is_empty() {
return Err(Error::Invalid(vec![format!(
"{} holds no numbered CSV files; Gradescope exports one file per question, \
named `1.csv` through `N.csv`",
dir.display()
)]));
}
// Numeric order, so `10.csv` does not sort before `2.csv`.
paths.sort_by_key(|p| {
p.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(u32::MAX)
});
let mut questions = Vec::new();
for p in &paths {
questions.push(parse_question(p)?);
}
Ok(to_responses(&questions, ctx))
}
/// Normalizes parsed questions into responses.
///
/// # Arguments
///
/// * `questions` - the parsed question files.
/// * `ctx` - identifying information.
///
/// # Returns
///
/// The import, with warnings for anything that looked wrong but not fatal.
pub fn to_responses(questions: &[Question], ctx: &Context) -> Import {
let mut set = ResponseSet::new();
let admin = administration_id(&ctx.course, &ctx.term, &ctx.assessment_id);
let mut counts: BTreeMap<u32, usize> = BTreeMap::new();
for q in questions {
let keyed: BTreeSet<String> = q.keyed().into_iter().collect();
let points = q.points_possible();
let bonus = q.looks_like_bonus();
if keyed.is_empty() {
set.warnings.push(format!(
"question {}: no keyed option could be identified from the point values, so \
correctness is unknown; scores are still recorded",
q.number
));
}
for (letter, value, note) in q.partial_credit() {
set.warnings.push(format!(
"question {}: option {letter} was awarded {value} of {points} points at grading \
time, which is recorded as an ambiguity signal{}",
q.number,
note.map(|n| format!(" ({n})")).unwrap_or_default()
));
}
for row in &q.rows {
let mut selected = Vec::new();
let mut eliminated = Vec::new();
let mut other = Vec::new();
for &i in &row.marks {
let c = &q.columns[i];
match (c.kind, &c.letter) {
(ColumnKind::Select, Some(l)) => selected.push(l.clone()),
(ColumnKind::Eliminate, Some(l)) => eliminated.push(l.clone()),
_ => other.push(c.label.clone()),
}
}
selected.sort();
eliminated.sort();
let credit = if points > 0.0 {
row.score / points
} else {
0.0
};
let correct = if keyed.is_empty() {
None
} else if selected.is_empty() && eliminated.is_empty() && other.is_empty() {
// A wholly unmarked row is a blank response, not a wrong one.
None
} else {
let chosen: BTreeSet<String> = selected.iter().cloned().collect();
Some(chosen == keyed)
};
let student_key = row
.sid
.clone()
.or_else(|| row.email.clone())
.unwrap_or_else(|| format!("unknown-{}", counts.len()));
*counts.entry(q.number).or_insert(0) += 1;
set.rows.push(Response {
administration_id: admin.clone(),
course: ctx.course.clone(),
term: ctx.term.clone(),
assessment_id: ctx.assessment_id.clone(),
date: ctx.date,
form: ctx.form.clone(),
student_key,
sid: row.sid.clone(),
name: row.name.clone(),
email: row.email.clone(),
section: row.section.clone(),
item_number: q.number,
item_ref: None,
item_version: None,
selected,
eliminated,
correct,
credit,
points_possible: points,
score: row.score,
response_time_seconds: None,
level: None,
learning_objectives: Vec::new(),
topics: Vec::new(),
bonus,
dropped: false,
});
}
}
// Every question should have the same number of submissions. A mismatch
// usually means a student was excused from one question, which is worth
// saying out loud because it changes per-item denominators.
let sizes: BTreeSet<usize> = counts.values().copied().collect();
if sizes.len() > 1 {
let mut odd: Vec<String> = Vec::new();
let modal = *sizes.iter().next_back().unwrap_or(&0);
for (n, c) in &counts {
if *c != modal {
odd.push(format!("q{n} has {c}"));
}
}
set.warnings.push(format!(
"submission counts differ across questions (most have {modal}): {}",
odd.join(", ")
));
}
Import {
responses: set,
questions: questions.to_vec(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_bare_letters() {
let (k, l, n) = classify("C");
assert_eq!(k, ColumnKind::Select);
assert_eq!(l, Some("C".to_string()));
assert_eq!(n, None);
}
#[test]
fn classifies_selected_and_eliminated_prefixes() {
assert_eq!(classify("Selected C").0, ColumnKind::Select);
assert_eq!(classify("Selected C").1, Some("C".to_string()));
assert_eq!(classify("Eliminated A").0, ColumnKind::Eliminate);
assert_eq!(classify("Eliminated A").1, Some("A".to_string()));
}
#[test]
fn keeps_the_instructors_annotation() {
// Verbatim from a real export.
let (k, l, n) = classify(
"B (Technically speaking, this answer describes HBD/HBA, but I can see how this \
distractor is poorly written.)",
);
assert_eq!(k, ColumnKind::Select);
assert_eq!(l, Some("B".to_string()));
assert!(n.unwrap().starts_with("Technically speaking"));
let (_, l2, n2) = classify("B (preparation does not select the one most likely to bind)");
assert_eq!(l2, Some("B".to_string()));
assert!(n2.is_some());
}
#[test]
fn rejects_prose_rubric_rows() {
// Also verbatim: these are genuinely not option columns.
let (k, l, _) = classify("Incorrect selection with no eliminations.");
assert_eq!(k, ColumnKind::Other);
assert_eq!(l, None);
assert_eq!(classify("").0, ColumnKind::Other);
}
#[test]
fn does_not_mistake_a_sentence_for_option_a() {
// Starts with `A`, but the next character is alphanumeric.
assert_eq!(classify("Answered in the margin").0, ColumnKind::Other);
}
fn q(max: f64, cols: &[(&str, f64)]) -> Question {
Question {
number: 1,
path: PathBuf::from("1.csv"),
columns: cols
.iter()
.map(|(label, v)| {
let (kind, letter, note) = classify(label);
RubricColumn {
label: label.to_string(),
kind,
letter,
note,
value: Some(*v),
}
})
.collect(),
max_points: max,
scoring_method: None,
rows: Vec::new(),
}
}
#[test]
fn finds_the_key_by_top_value() {
let question = q(1.5, &[("A", 0.0), ("B", 0.0), ("C", 1.5), ("D", 0.0)]);
assert_eq!(question.keyed(), vec!["C".to_string()]);
assert!(question.partial_credit().is_empty());
}
#[test]
fn finds_the_key_on_a_bonus_question_with_zero_maximum() {
// Real case: q31 and q32 of the sample exam.
let question = q(0.0, &[("A", 0.0), ("B", 1.5), ("C", 0.0)]);
assert!(question.looks_like_bonus());
assert_eq!(question.keyed(), vec!["B".to_string()]);
assert_eq!(question.points_possible(), 1.5);
}
#[test]
fn partial_credit_to_a_distractor_flags_ambiguity() {
// Real case: q14, where B earned 1.49 of 1.5.
let question = q(1.5, &[("A", 0.0), ("B (poorly written)", 1.49), ("C", 1.5)]);
assert_eq!(question.keyed(), vec!["C".to_string()]);
let partial = question.partial_credit();
assert_eq!(partial.len(), 1);
assert_eq!(partial[0].0, "B");
assert!(partial[0].2.is_some(), "keeps the note");
assert_eq!(question.implied_flags(), vec![Flag::Ambiguous]);
}
#[test]
fn detects_elimination_scoring() {
let question = q(
0.9,
&[
("Selected C", 0.9),
("Eliminated A", 0.3),
("Eliminated B", 0.3),
],
);
assert!(question.uses_elimination());
assert_eq!(question.keyed(), vec!["C".to_string()]);
}
#[test]
fn parses_a_realistic_file() {
let dir = std::env::temp_dir().join(format!("cb-gs-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("7.csv");
std::fs::write(
&path,
"Assignment Submission ID,Question Submission ID,First Name,Last Name,SID,Email,\
Sections,Score,Submission Time,A,B,C,D,Adjustment,Comments,Grader,Tags\n\
1,11,Ada,Lovelace,1234567,ada@x.edu,L01,1.5,2026-04-01T10:00:00Z,false,false,true,\
false,,,,\n\
2,12,Alan,Turing,7654321,alan@x.edu,L01,0.0,2026-04-01T10:05:00Z,true,false,false,\
false,,,,\n\
3,13,Grace,Hopper,1111111,grace@x.edu,L02,0.0,2026-04-01T10:06:00Z,false,false,\
false,false,,,,\n\
Point Values,,,,,1.5,0,0,1.5,0\n\
Scoring Method,positive\n",
)
.unwrap();
let question = parse_question(&path).unwrap();
assert_eq!(question.number, 7);
assert_eq!(question.columns.len(), 4, "four rubric columns");
assert_eq!(question.max_points, 1.5);
assert_eq!(question.keyed(), vec!["C".to_string()]);
assert_eq!(question.rows.len(), 3, "trailing rows are not students");
assert_eq!(question.scoring_method.as_deref(), Some("positive"));
let ctx = Context {
course: "BIOSC1540".into(),
term: "2026s".into(),
assessment_id: "exam-4".into(),
date: None,
form: None,
};
let import = to_responses(&[question], &ctx);
let rows = &import.responses.rows;
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].selected, vec!["C".to_string()]);
assert_eq!(rows[0].correct, Some(true));
assert_eq!(rows[0].credit, 1.0);
assert_eq!(rows[1].correct, Some(false));
// A student who marked nothing left it blank; that is not a wrong answer.
assert_eq!(rows[2].correct, None);
assert_eq!(rows[2].selected.len(), 0);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn rejects_a_file_without_the_rubric_delimiter() {
let dir = std::env::temp_dir().join(format!("cb-gs-bad-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("1.csv");
std::fs::write(&path, "Name,Score\nAda,1\n").unwrap();
let err = parse_question(&path).unwrap_err();
assert!(err.to_string().contains("Submission Time"));
std::fs::remove_dir_all(&dir).ok();
}
}
+835
View File
@@ -0,0 +1,835 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! The canonical response row.
//!
//! Every grading platform exports a different shape. Gradescope gives one file
//! per question, in wide form, with a column per rubric item. Canvas gives one
//! enormous file per quiz, in wide form, with two columns per question and answer
//! *text* instead of option letters. Neither shape is analyzable.
//!
//! So both are normalized into the long form defined here: one row per student
//! per item. Long form is what item analysis, IRT, and per-objective mastery all
//! want, it survives a question being added or dropped without changing the
//! schema, and it appends cleanly across terms — which is the whole point, since
//! item statistics only become trustworthy once several administrations are
//! pooled.
//!
//! Two fields deserve comment.
//!
//! `credit` is a *fraction* in `0.0..=1.0`, not points. Storing the fraction
//! keeps the response independent of the points an item happened to be worth on
//! one exam, so pooling across administrations that weighted an item differently
//! is still valid. `score` carries the points actually awarded.
//!
//! `student_key` is whatever identifier analysis should group by, and it may be a
//! pseudonym. The real SID lives in `sid`, which is dropped when pseudonymizing.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::assessment::AssessmentFile;
use crate::catalog::Catalog;
use crate::date::Date;
use crate::hash::pseudonym;
use crate::taxonomy::Level;
/// One student's response to one item on one administration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Response {
/// Identifies this administration: course slug, term, and assessment id.
/// Rows from different administrations of the same exam differ here, which is
/// what makes pooled analysis separable again later.
pub administration_id: String,
/// The course code.
pub course: String,
/// The term, e.g. `2026s`.
pub term: String,
/// The assessment id.
pub assessment_id: String,
/// The administration date.
pub date: Option<Date>,
/// Which form the student took, when forms were used.
pub form: Option<String>,
/// The identifier analysis groups by. A pseudonym when pseudonymizing.
pub student_key: String,
/// The institutional student id, absent when pseudonymized.
pub sid: Option<String>,
/// The student's name, absent when pseudonymized.
pub name: Option<String>,
/// The student's email, absent when pseudonymized.
pub email: Option<String>,
/// Section or lab, kept because it is the grouping most likely to reveal a
/// delivery problem rather than a learning one.
pub section: Option<String>,
/// The question number on the form, which is the join key to the record.
pub item_number: u32,
/// The item's global id, once resolved against an assessment record.
pub item_ref: Option<String>,
/// The item version as administered.
pub item_version: Option<u32>,
/// Option letters the student chose.
pub selected: Vec<String>,
/// Option letters the student eliminated, for elimination-scored items.
pub eliminated: 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>,
/// Fraction of the item's points earned, in `0.0..=1.0`. May exceed nothing
/// and may go negative on elimination scoring.
pub credit: f64,
/// Points the item was worth as administered.
pub points_possible: f64,
/// Points awarded, authoritative from the platform where available.
pub score: f64,
/// Seconds spent, when the platform reports it.
pub response_time_seconds: Option<f64>,
/// The item's level, denormalized so analysis need not carry the catalog.
pub level: Option<Level>,
/// The item's learning objectives, denormalized for per-objective mastery.
pub learning_objectives: Vec<String>,
/// The item's topics, denormalized.
pub topics: Vec<String>,
/// Whether the item was bonus, and so excluded from the scored total.
pub bonus: bool,
/// Whether the item was dropped after the fact.
pub dropped: bool,
}
impl Response {
/// Whether this row should count toward scored totals and item statistics.
///
/// # Returns
///
/// `true` for a scored, undropped item.
pub fn counts(&self) -> bool {
!self.bonus && !self.dropped
}
/// The response coded for a dichotomous model.
///
/// Partial credit is rounded toward the majority: a half-credit response is
/// coded incorrect. IRT here is dichotomous, and pretending otherwise would
/// misstate the model rather than the data.
///
/// # Returns
///
/// `Some(true)` for full credit, `Some(false)` for less, `None` when unknown.
pub fn dichotomous(&self) -> Option<bool> {
match self.correct {
Some(c) => Some(c),
None if self.credit > 0.0 => Some(self.credit >= 0.999),
None => None,
}
}
/// The selected options as a comma-joined string, for flat storage.
pub fn selected_joined(&self) -> String {
self.selected.join(",")
}
}
/// A set of responses plus anything worth telling the user about the ingest.
#[derive(Debug, Clone, Default)]
pub struct ResponseSet {
/// The rows, in ingest order.
pub rows: Vec<Response>,
/// Non-fatal problems: unmatched columns, students with no responses,
/// question numbers absent from the assessment record.
pub warnings: Vec<String>,
}
impl ResponseSet {
/// Creates an empty set.
pub fn new() -> ResponseSet {
ResponseSet::default()
}
/// Appends another set, keeping its warnings.
///
/// # Arguments
///
/// * `other` - the set to absorb.
pub fn absorb(&mut self, other: ResponseSet) {
self.rows.extend(other.rows);
self.warnings.extend(other.warnings);
}
/// The distinct student keys, in sorted order.
pub fn students(&self) -> Vec<String> {
let set: BTreeSet<&str> = self.rows.iter().map(|r| r.student_key.as_str()).collect();
set.into_iter().map(|s| s.to_string()).collect()
}
/// The distinct item numbers that count toward the scored total, sorted.
pub fn scored_items(&self) -> Vec<u32> {
let set: BTreeSet<u32> = self
.rows
.iter()
.filter(|r| r.counts())
.map(|r| r.item_number)
.collect();
set.into_iter().collect()
}
/// All distinct item numbers, sorted.
pub fn all_items(&self) -> Vec<u32> {
let set: BTreeSet<u32> = self.rows.iter().map(|r| r.item_number).collect();
set.into_iter().collect()
}
/// Every row for one item, in student order.
///
/// # Arguments
///
/// * `number` - the question number.
///
/// # Returns
///
/// The matching rows.
pub fn for_item(&self, number: u32) -> Vec<&Response> {
let mut v: Vec<&Response> = self
.rows
.iter()
.filter(|r| r.item_number == number)
.collect();
v.sort_by(|a, b| a.student_key.cmp(&b.student_key));
v
}
/// Every row for one student, in item order.
///
/// # Arguments
///
/// * `key` - the student key.
///
/// # Returns
///
/// The matching rows.
pub fn for_student(&self, key: &str) -> Vec<&Response> {
let mut v: Vec<&Response> = self.rows.iter().filter(|r| r.student_key == key).collect();
v.sort_by_key(|r| r.item_number);
v
}
/// Total points a student earned on scored items.
///
/// # Arguments
///
/// * `key` - the student key.
///
/// # Returns
///
/// The sum of `score` over scored, undropped items.
pub fn scored_total(&self, key: &str) -> f64 {
self.rows
.iter()
.filter(|r| r.student_key == key && r.counts())
.map(|r| r.score)
.sum()
}
/// Bonus points a student earned.
pub fn bonus_total(&self, key: &str) -> f64 {
self.rows
.iter()
.filter(|r| r.student_key == key && r.bonus && !r.dropped)
.map(|r| r.score)
.sum()
}
/// Points available on scored items, taken from the most generous row seen
/// 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()) {
let e = per_item.entry(r.item_number).or_insert(0.0);
if r.points_possible > *e {
*e = r.points_possible;
}
}
per_item.values().sum()
}
/// Builds the response matrix for psychometrics.
///
/// # Arguments
///
/// * `include_bonus` - whether bonus items belong in the matrix. They
/// normally do not: bonus items are usually hard and optional, so including
/// them inflates the appearance of a low-ability tail.
///
/// # Returns
///
/// The matrix, students by items.
pub fn matrix(&self, include_bonus: bool) -> Matrix {
let students = self.students();
let items: Vec<u32> = if include_bonus {
self.all_items()
.into_iter()
.filter(|n| !self.item_dropped(*n))
.collect()
} else {
self.scored_items()
};
let student_index: BTreeMap<&str, usize> = students
.iter()
.enumerate()
.map(|(i, s)| (s.as_str(), i))
.collect();
let item_index: BTreeMap<u32, usize> =
items.iter().enumerate().map(|(i, n)| (*n, i)).collect();
let mut credit = vec![vec![None; items.len()]; students.len()];
let mut coded = vec![vec![None; items.len()]; students.len()];
for r in &self.rows {
let Some(&si) = student_index.get(r.student_key.as_str()) else {
continue;
};
let Some(&ii) = item_index.get(&r.item_number) else {
continue;
};
credit[si][ii] = Some(r.credit);
coded[si][ii] = r.dichotomous().map(|c| if c { 1u8 } else { 0u8 });
}
Matrix {
students,
items,
credit,
coded,
}
}
/// Whether every row for an item is marked dropped.
///
/// # Arguments
///
/// * `number` - the question number.
///
/// # Returns
///
/// `true` when the item was dropped.
pub fn item_dropped(&self, number: u32) -> bool {
let mut any = false;
for r in self.rows.iter().filter(|r| r.item_number == number) {
any = true;
if !r.dropped {
return false;
}
}
any
}
/// Attaches item metadata from an assessment record and the catalog.
///
/// Ingest knows question numbers; only the record knows which item a number
/// referred to. Doing this as a separate pass means an export can be parsed
/// and inspected before the record is written, which is the order people
/// actually work in.
///
/// # Arguments
///
/// * `record` - the assessment record.
/// * `catalog` - the loaded course, for levels, objectives, and topics.
///
/// # Returns
///
/// Warnings for numbers absent from the record and for keys that disagree
/// with the record.
pub fn enrich(&mut self, record: &AssessmentFile, catalog: Option<&Catalog>) -> Vec<String> {
let mut warnings = Vec::new();
let mut unmatched: BTreeSet<u32> = BTreeSet::new();
let default_points = catalog
.map(|c| c.course.policy.points_per_item)
.unwrap_or(1.0);
for r in &mut self.rows {
let Some(p) = record.placement(r.item_number) else {
unmatched.insert(r.item_number);
continue;
};
r.item_ref = Some(p.item.clone());
r.item_version = p.version;
r.bonus = r.bonus || p.bonus;
r.dropped = r.dropped || p.dropped;
if let Some(points) = p.points {
// The record is authoritative for points as administered; the
// export sometimes carries a stale maximum.
if (points - r.points_possible).abs() > 1e-9 && r.points_possible > 0.0 {
let ratio = r.credit;
r.points_possible = points;
r.score = ratio * points;
}
if r.points_possible == 0.0 {
r.points_possible = points;
}
}
if let Some(cat) = catalog {
if let Some(entry) = cat.get(&p.item) {
r.level = Some(entry.item.level);
r.learning_objectives = if p.learning_objectives.is_empty() {
entry.item.learning_objectives.clone()
} else {
p.learning_objectives.clone()
};
r.topics = entry.item.topics.clone();
if r.points_possible == 0.0 && !p.bonus {
r.points_possible = entry.item.points(default_points);
}
}
} else {
r.level = p.level;
r.learning_objectives = p.learning_objectives.clone();
}
// Apply the record's credit overrides, which is how a decision to
// award partial credit after the fact becomes visible in analysis.
if !p.credit_overrides.is_empty() && r.selected.len() == 1 {
if let Some(over) = p.credit_overrides.get(&r.selected[0]) {
if (*over - r.credit).abs() > 1e-9 {
r.credit = *over;
r.score = *over * r.points_possible;
r.correct = Some(*over >= 0.999);
}
}
}
}
if !unmatched.is_empty() {
let list: Vec<String> = unmatched.iter().map(|n| n.to_string()).collect();
warnings.push(format!(
"question number(s) {} appear in the export but not in the assessment record; \
they will be analyzed without item metadata",
list.join(", ")
));
}
self.warnings.extend(warnings.clone());
warnings
}
/// Replaces identifiers with keyed pseudonyms.
///
/// The salt must be kept outside the course repository. Hashing a seven-digit
/// student id without a key is not de-identification: the whole space can be
/// enumerated in under a second, so anyone with the file recovers every id.
///
/// # Arguments
///
/// * `salt` - the HMAC key.
pub fn pseudonymize(&mut self, salt: &[u8]) {
for r in &mut self.rows {
let source = r
.sid
.clone()
.or_else(|| r.email.clone())
.unwrap_or_else(|| r.student_key.clone());
r.student_key = pseudonym(salt, &source, 12);
r.sid = None;
r.name = None;
r.email = None;
}
}
/// The administration ids present, sorted.
pub fn administrations(&self) -> Vec<String> {
let set: BTreeSet<&str> = self
.rows
.iter()
.map(|r| r.administration_id.as_str())
.collect();
set.into_iter().map(|s| s.to_string()).collect()
}
}
/// Builds an administration id.
///
/// # Arguments
///
/// * `course` - the course code.
/// * `term` - the term.
/// * `assessment` - the assessment id.
///
/// # Returns
///
/// A stable identifier such as `BIOSC1540/2026s/exam-4`.
pub fn administration_id(course: &str, term: &str, assessment: &str) -> String {
format!("{course}/{term}/{assessment}")
}
/// A response matrix, students by items.
#[derive(Debug, Clone)]
pub struct Matrix {
/// Student keys, one per row.
pub students: Vec<String>,
/// Question numbers, one per column.
pub items: Vec<u32>,
/// Credit fractions; `None` for a missing response.
pub credit: Vec<Vec<Option<f64>>>,
/// Dichotomous codes; `None` for a missing response.
pub coded: Vec<Vec<Option<u8>>>,
}
impl Matrix {
/// The number of examinees.
pub fn n_students(&self) -> usize {
self.students.len()
}
/// The number of items.
pub fn n_items(&self) -> usize {
self.items.len()
}
/// Per-student total of credit fractions, treating missing as zero.
///
/// # Returns
///
/// One total per student, in row order.
pub fn totals(&self) -> Vec<f64> {
self.credit
.iter()
.map(|row| row.iter().map(|c| c.unwrap_or(0.0)).sum())
.collect()
}
/// Per-student count of items answered correctly.
pub fn correct_counts(&self) -> Vec<f64> {
self.coded
.iter()
.map(|row| row.iter().map(|c| c.unwrap_or(0) as f64).sum())
.collect()
}
/// The column for one item, by index.
///
/// # Arguments
///
/// * `j` - the column index.
///
/// # Returns
///
/// The dichotomous codes down that column.
pub fn column(&self, j: usize) -> Vec<Option<u8>> {
self.coded.iter().map(|row| row[j]).collect()
}
/// Whether the matrix has enough data to analyze at all.
///
/// # Returns
///
/// `true` when there is at least one student and one item.
pub fn is_analyzable(&self) -> bool {
self.n_students() > 0 && self.n_items() > 0
}
}
/// A flat record for CSV and Parquet storage.
///
/// The nested vectors on [`Response`] do not survive a columnar format, so they
/// are joined here. This is the schema written to disk, and it is deliberately
/// wide and denormalized: it is a fact table, meant to be appended to and read
/// by other tools, not a normalized database.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlatResponse {
/// Identifies the administration.
pub administration_id: String,
/// The course code.
pub course: String,
/// The term.
pub term: String,
/// The assessment id.
pub assessment_id: String,
/// The date as `YYYY-MM-DD`, empty when unknown.
pub date: String,
/// The form id, empty when there were no forms.
pub form: String,
/// The grouping key.
pub student_key: String,
/// The student id, empty when pseudonymized.
pub sid: String,
/// The student email, empty when pseudonymized.
pub email: String,
/// The section, empty when unknown.
pub section: String,
/// The question number.
pub item_number: u32,
/// The item's global id, empty when unresolved.
pub item_ref: String,
/// The item version, 0 when unknown.
pub item_version: u32,
/// Comma-joined selected letters.
pub selected: String,
/// Comma-joined eliminated letters.
pub eliminated: String,
/// `1`, `0`, or empty when unknown.
pub correct: String,
/// Credit fraction.
pub credit: f64,
/// Points possible.
pub points_possible: f64,
/// Points awarded.
pub score: f64,
/// Seconds spent, empty when unknown.
pub response_time_seconds: String,
/// The level code 1..5, 0 when unknown.
pub level: u8,
/// Comma-joined objective ids.
pub learning_objectives: String,
/// Comma-joined topics.
pub topics: String,
/// Whether the item was bonus.
pub bonus: bool,
/// Whether the item was dropped.
pub dropped: bool,
}
impl FlatResponse {
/// Flattens a response.
///
/// # Arguments
///
/// * `r` - the response.
///
/// # Returns
///
/// The flat record.
pub fn from_response(r: &Response) -> FlatResponse {
FlatResponse {
administration_id: r.administration_id.clone(),
course: r.course.clone(),
term: r.term.clone(),
assessment_id: r.assessment_id.clone(),
date: r.date.map(|d| d.to_string()).unwrap_or_default(),
form: r.form.clone().unwrap_or_default(),
student_key: r.student_key.clone(),
sid: r.sid.clone().unwrap_or_default(),
email: r.email.clone().unwrap_or_default(),
section: r.section.clone().unwrap_or_default(),
item_number: r.item_number,
item_ref: r.item_ref.clone().unwrap_or_default(),
item_version: r.item_version.unwrap_or(0),
selected: r.selected.join(","),
eliminated: r.eliminated.join(","),
correct: match r.correct {
Some(true) => "1".to_string(),
Some(false) => "0".to_string(),
None => String::new(),
},
credit: r.credit,
points_possible: r.points_possible,
score: r.score,
response_time_seconds: r
.response_time_seconds
.map(|s| format!("{s:.1}"))
.unwrap_or_default(),
level: r.level.map(|l| l.code()).unwrap_or(0),
learning_objectives: r.learning_objectives.join(","),
topics: r.topics.join(","),
bonus: r.bonus,
dropped: r.dropped,
}
}
/// Rebuilds a response from its flat form.
///
/// # Returns
///
/// The response. Unparseable optional fields become `None` rather than
/// failing the read, because a hand-edited CSV should still load.
pub fn to_response(&self) -> Response {
let split = |s: &str| -> Vec<String> {
s.split(',')
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(|p| p.to_string())
.collect()
};
Response {
administration_id: self.administration_id.clone(),
course: self.course.clone(),
term: self.term.clone(),
assessment_id: self.assessment_id.clone(),
date: self.date.parse().ok(),
form: none_if_empty(&self.form),
student_key: self.student_key.clone(),
sid: none_if_empty(&self.sid),
name: None,
email: none_if_empty(&self.email),
section: none_if_empty(&self.section),
item_number: self.item_number,
item_ref: none_if_empty(&self.item_ref),
item_version: if self.item_version == 0 {
None
} else {
Some(self.item_version)
},
selected: split(&self.selected),
eliminated: split(&self.eliminated),
correct: match self.correct.as_str() {
"1" | "true" => Some(true),
"0" | "false" => Some(false),
_ => None,
},
credit: self.credit,
points_possible: self.points_possible,
score: self.score,
response_time_seconds: self.response_time_seconds.parse().ok(),
level: Level::from_code(self.level),
learning_objectives: split(&self.learning_objectives),
topics: split(&self.topics),
bonus: self.bonus,
dropped: self.dropped,
}
}
}
/// `None` for an empty string, `Some` otherwise.
fn none_if_empty(s: &str) -> Option<String> {
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row(student: &str, number: u32, credit: f64) -> Response {
Response {
administration_id: "C/2026s/e1".into(),
course: "C".into(),
term: "2026s".into(),
assessment_id: "e1".into(),
date: None,
form: None,
student_key: student.into(),
sid: Some(format!("sid-{student}")),
name: None,
email: None,
section: None,
item_number: number,
item_ref: None,
item_version: None,
selected: vec!["A".into()],
eliminated: vec![],
correct: Some(credit >= 0.999),
credit,
points_possible: 2.0,
score: credit * 2.0,
response_time_seconds: None,
level: None,
learning_objectives: vec![],
topics: vec![],
bonus: false,
dropped: false,
}
}
#[test]
fn matrix_is_students_by_items() {
let mut set = ResponseSet::new();
set.rows.push(row("s1", 1, 1.0));
set.rows.push(row("s1", 2, 0.0));
set.rows.push(row("s2", 1, 1.0));
set.rows.push(row("s2", 2, 1.0));
let m = set.matrix(false);
assert_eq!(m.n_students(), 2);
assert_eq!(m.n_items(), 2);
assert_eq!(m.coded[0], vec![Some(1), Some(0)]);
assert_eq!(m.correct_counts(), vec![1.0, 2.0]);
}
#[test]
fn missing_responses_stay_missing() {
let mut set = ResponseSet::new();
set.rows.push(row("s1", 1, 1.0));
set.rows.push(row("s2", 2, 1.0));
let m = set.matrix(false);
// s1 never answered item 2, so that cell is absent rather than zero.
assert_eq!(m.coded[0][1], None);
assert_eq!(m.coded[1][0], None);
// Totals treat missing as zero, which is right for scoring.
assert_eq!(m.totals(), vec![1.0, 1.0]);
}
#[test]
fn bonus_items_are_excluded_by_default() {
let mut set = ResponseSet::new();
set.rows.push(row("s1", 1, 1.0));
let mut bonus = row("s1", 2, 1.0);
bonus.bonus = true;
set.rows.push(bonus);
assert_eq!(set.matrix(false).n_items(), 1);
assert_eq!(set.matrix(true).n_items(), 2);
assert_eq!(set.scored_total("s1"), 2.0);
assert_eq!(set.bonus_total("s1"), 2.0);
}
#[test]
fn dropped_items_leave_the_matrix() {
let mut set = ResponseSet::new();
let mut r = row("s1", 1, 0.0);
r.dropped = true;
set.rows.push(r);
set.rows.push(row("s1", 2, 1.0));
assert_eq!(set.matrix(false).items, vec![2]);
assert!(set.item_dropped(1));
assert!(!set.item_dropped(2));
}
#[test]
fn partial_credit_codes_as_incorrect_for_irt() {
let mut r = row("s1", 1, 0.5);
r.correct = None;
assert_eq!(r.dichotomous(), Some(false));
r.credit = 1.0;
assert_eq!(r.dichotomous(), Some(true));
}
#[test]
fn pseudonymizing_removes_identifiers() {
let mut set = ResponseSet::new();
set.rows.push(row("s1", 1, 1.0));
let before = set.rows[0].student_key.clone();
set.pseudonymize(b"secret-salt");
assert_ne!(set.rows[0].student_key, before);
assert!(set.rows[0].student_key.starts_with("s-"));
assert!(set.rows[0].sid.is_none());
assert!(set.rows[0].name.is_none());
}
#[test]
fn flattening_round_trips() {
let r = row("s1", 3, 0.5);
let flat = FlatResponse::from_response(&r);
let back = flat.to_response();
assert_eq!(back.student_key, "s1");
assert_eq!(back.item_number, 3);
assert_eq!(back.credit, 0.5);
assert_eq!(back.selected, vec!["A".to_string()]);
assert_eq!(back.correct, Some(false));
}
#[test]
fn points_available_uses_the_largest_seen_per_item() {
let mut set = ResponseSet::new();
set.rows.push(row("s1", 1, 1.0));
let mut r = row("s2", 1, 1.0);
r.points_possible = 3.0;
set.rows.push(r);
assert_eq!(set.points_available(), 3.0);
}
}
+669
View File
@@ -0,0 +1,669 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Where response data lives on disk.
//!
//! One file per administration, under `data/`, named after the administration id.
//! Not one big file, because an exam's responses are written once and then only
//! read: separate files mean re-ingesting Exam 4 cannot corrupt Exam 3, and a
//! term's data can be archived or excluded by moving files rather than filtering
//! rows.
//!
//! Parquet is the default format. It is columnar, typed, compressed, and readable
//! by pandas, polars, R, and DuckDB without an export step, which matters because
//! the point of storing this data is to still be able to analyze it in five years
//! with whatever tool exists then.
//!
//! CSV is the fallback, and it is a real fallback rather than a degraded mode:
//! `--no-default-features` builds the entire tool with CSV storage and loses
//! nothing but file size and read speed. Committing to a format you cannot open
//! without the right library version is how course data gets lost.
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::responses::{FlatResponse, Response, ResponseSet};
/// A storage format.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
/// Apache Parquet, the default.
Parquet,
/// Comma-separated values.
Csv,
}
impl Format {
/// The file extension, without a dot.
pub fn extension(self) -> &'static str {
match self {
Format::Parquet => "parquet",
Format::Csv => "csv",
}
}
/// The format compiled in as the default.
///
/// # Returns
///
/// Parquet when the `parquet` feature is on, CSV otherwise.
pub fn preferred() -> Format {
#[cfg(feature = "parquet")]
{
Format::Parquet
}
#[cfg(not(feature = "parquet"))]
{
Format::Csv
}
}
/// 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"),
}
}
/// The format implied by a file extension.
///
/// # Arguments
///
/// * `path` - the file path.
///
/// # Returns
///
/// The format, or `None` when the extension is not one we write.
pub fn from_path(path: &Path) -> Option<Format> {
match path.extension().and_then(|e| e.to_str()) {
Some("parquet") => Some(Format::Parquet),
Some("csv") => Some(Format::Csv),
_ => None,
}
}
}
/// The response store rooted at a course's `data/` directory.
#[derive(Debug, Clone)]
pub struct Store {
/// The data directory.
pub dir: PathBuf,
/// The format to write.
pub format: Format,
}
impl Store {
/// Opens a store, creating the directory if needed.
///
/// # Arguments
///
/// * `dir` - the data directory.
///
/// # Returns
///
/// The store, writing the preferred available format.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory cannot be created.
pub fn open(dir: impl Into<PathBuf>) -> Result<Store> {
let dir = dir.into();
std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
Ok(Store {
dir,
format: Format::preferred(),
})
}
/// Sets the format to write.
///
/// # Arguments
///
/// * `format` - the format.
///
/// # Returns
///
/// The store, for chaining.
///
/// # Errors
///
/// Returns [`Error::FeatureDisabled`] when the format was compiled out.
pub fn with_format(mut self, format: Format) -> Result<Store> {
if !format.is_available() {
return Err(Error::FeatureDisabled("Parquet", "parquet"));
}
self.format = format;
Ok(self)
}
/// The path for an administration's responses.
///
/// # Arguments
///
/// * `administration_id` - the administration id.
///
/// # Returns
///
/// The path, with slashes in the id replaced so it is one file.
pub fn path_for(&self, administration_id: &str) -> PathBuf {
self.dir.join(format!(
"{}.{}",
sanitize(administration_id),
self.format.extension()
))
}
/// Writes one administration's responses, replacing any existing file.
///
/// Replacing rather than appending is deliberate. Re-ingesting after a
/// regrade should produce the corrected data, not two contradictory copies of
/// the same student's response, and there is no way to tell those apart later.
///
/// # Arguments
///
/// * `set` - the responses, which must all share one administration id.
///
/// # Returns
///
/// The paths written, one per administration found in the set.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure and [`Error::FeatureDisabled`]
/// when writing Parquet without the feature.
pub fn write(&self, set: &ResponseSet) -> Result<Vec<PathBuf>> {
let mut written = Vec::new();
for admin in set.administrations() {
let rows: Vec<FlatResponse> = set
.rows
.iter()
.filter(|r| r.administration_id == admin)
.map(FlatResponse::from_response)
.collect();
let path = self.path_for(&admin);
match self.format {
Format::Csv => write_csv(&path, &rows)?,
Format::Parquet => write_parquet(&path, &rows)?,
}
written.push(path);
}
Ok(written)
}
/// Reads one administration's responses.
///
/// # Arguments
///
/// * `administration_id` - the administration id.
///
/// # Returns
///
/// The responses.
///
/// # Errors
///
/// Returns [`Error::Io`] when the file is missing.
pub fn read(&self, administration_id: &str) -> Result<ResponseSet> {
// Accept either format regardless of what this build prefers, so a repo
// written by a Parquet build is still readable by a lean one where the
// CSV happens to exist, and vice versa.
let stem = sanitize(administration_id);
for format in [self.format, Format::Parquet, Format::Csv] {
let path = self.dir.join(format!("{stem}.{}", format.extension()));
if path.exists() {
return read_path(&path);
}
}
Err(Error::Other(format!(
"no stored responses for `{administration_id}` in {}",
self.dir.display()
)))
}
/// Every stored data file, sorted.
///
/// # Returns
///
/// The paths.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory cannot be listed.
pub fn files(&self) -> Result<Vec<PathBuf>> {
if !self.dir.exists() {
return Ok(Vec::new());
}
let mut out: Vec<PathBuf> = std::fs::read_dir(&self.dir)
.map_err(|e| Error::io(&self.dir, e))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| Format::from_path(p).is_some())
.collect();
out.sort();
Ok(out)
}
/// Reads every stored administration.
///
/// This is what pooled item statistics run on: several administrations of the
/// same item, which is the only way the numbers become trustworthy for a class
/// of twenty-five.
///
/// # Returns
///
/// All responses, with one file's failure recorded as a warning rather than
/// aborting the rest.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory cannot be listed.
pub fn read_all(&self) -> Result<ResponseSet> {
let mut set = ResponseSet::new();
for path in self.files()? {
match read_path(&path) {
Ok(part) => set.absorb(part),
Err(e) => set
.warnings
.push(format!("skipping {}: {e}", path.display())),
}
}
Ok(set)
}
/// Reads every administration of one assessment, across terms.
///
/// # Arguments
///
/// * `assessment_id` - the assessment id to match.
///
/// # Returns
///
/// The matching responses.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory cannot be listed.
pub fn read_assessment(&self, assessment_id: &str) -> Result<ResponseSet> {
let all = self.read_all()?;
let mut set = ResponseSet::new();
set.warnings = all.warnings;
set.rows = all
.rows
.into_iter()
.filter(|r| r.assessment_id == assessment_id)
.collect();
Ok(set)
}
}
/// Reads a data file, choosing the reader by extension.
///
/// # Arguments
///
/// * `path` - the file.
///
/// # Returns
///
/// The responses.
///
/// # Errors
///
/// Returns [`Error::Other`] for an unrecognized extension and
/// [`Error::FeatureDisabled`] for Parquet without the feature.
pub fn read_path(path: &Path) -> Result<ResponseSet> {
match Format::from_path(path) {
Some(Format::Csv) => read_csv(path),
Some(Format::Parquet) => read_parquet(path),
None => Err(Error::Other(format!(
"{} is not a response file; expected a .parquet or .csv",
path.display()
))),
}
}
/// Writes flat responses as CSV.
///
/// # Arguments
///
/// * `path` - the destination.
/// * `rows` - the rows.
///
/// # Errors
///
/// Returns [`Error::Csv`] on a serialization failure.
fn write_csv(path: &Path, rows: &[FlatResponse]) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
}
let mut w = csv::Writer::from_path(path).map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
for r in rows {
w.serialize(r).map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
}
w.flush().map_err(|e| Error::io(path, e))?;
Ok(())
}
/// Reads flat responses from CSV.
///
/// # Arguments
///
/// * `path` - the file.
///
/// # Returns
///
/// The responses.
///
/// # Errors
///
/// Returns [`Error::Csv`] on a parse failure.
fn read_csv(path: &Path) -> Result<ResponseSet> {
let mut r = csv::Reader::from_path(path).map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
let mut set = ResponseSet::new();
for rec in r.deserialize::<FlatResponse>() {
let flat = rec.map_err(|e| Error::Csv {
path: path.to_path_buf(),
source: e,
})?;
set.rows.push(flat.to_response());
}
Ok(set)
}
/// Writes flat responses as Parquet.
///
/// # Arguments
///
/// * `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
///
/// * `path` - the file.
///
/// # Returns
///
/// The responses.
///
/// # 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();
set.rows = rows.iter().map(|f| f.to_response()).collect();
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
///
/// * `s` - the id.
///
/// # Returns
///
/// The sanitized stem.
pub fn sanitize(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut last_sep = false;
for ch in s.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '.' {
out.push(ch.to_ascii_lowercase());
last_sep = false;
} else if !last_sep {
out.push('_');
last_sep = true;
}
}
let trimmed = out.trim_matches('_').to_string();
if trimmed.is_empty() {
"responses".to_string()
} else {
trimmed
}
}
/// Exports responses to an arbitrary path, in the format its extension implies.
///
/// This exists so `export` is a separate verb from `ingest`: the store is the
/// system of record, and handing a colleague a CSV should not change it.
///
/// # Arguments
///
/// * `path` - the destination.
/// * `set` - the responses.
///
/// # Errors
///
/// Returns [`Error::Other`] for an unrecognized extension.
pub fn export(path: &Path, set: &ResponseSet) -> Result<()> {
let rows: Vec<FlatResponse> = set.rows.iter().map(FlatResponse::from_response).collect();
match Format::from_path(path) {
Some(Format::Csv) => write_csv(path, &rows),
Some(Format::Parquet) => write_parquet(path, &rows),
None => Err(Error::Other(format!(
"cannot tell what format {} should be; use a .csv or .parquet extension",
path.display()
))),
}
}
/// Summarizes what is in the store, for `coursebank data list`.
#[derive(Debug, Clone)]
pub struct StoredSummary {
/// The administration id.
pub administration_id: String,
/// The file.
pub path: PathBuf,
/// How many response rows.
pub rows: usize,
/// How many distinct students.
pub students: usize,
/// How many distinct items.
pub items: usize,
}
/// Summarizes every stored administration.
///
/// # Arguments
///
/// * `store` - the store.
///
/// # Returns
///
/// One summary per file, sorted by path.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory cannot be listed.
pub fn summarize(store: &Store) -> Result<Vec<StoredSummary>> {
let mut out = Vec::new();
for path in store.files()? {
let set = match read_path(&path) {
Ok(s) => s,
Err(_) => continue,
};
let admin = set.administrations().first().cloned().unwrap_or_else(|| {
path.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into()
});
out.push(StoredSummary {
administration_id: admin,
path,
rows: set.rows.len(),
students: set.students().len(),
items: set.all_items().len(),
});
}
Ok(out)
}
/// Groups responses by administration.
///
/// # Arguments
///
/// * `set` - the responses.
///
/// # Returns
///
/// One set per administration, keyed by id.
pub fn split_by_administration(
set: &ResponseSet,
) -> std::collections::BTreeMap<String, Vec<&Response>> {
let mut out: std::collections::BTreeMap<String, Vec<&Response>> = Default::default();
for r in &set.rows {
out.entry(r.administration_id.clone()).or_default().push(r);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::responses::Response;
fn row(student: &str, number: u32) -> Response {
Response {
administration_id: "BIOSC1540/2026s/exam-4".into(),
course: "BIOSC1540".into(),
term: "2026s".into(),
assessment_id: "exam-4".into(),
date: None,
form: None,
student_key: student.into(),
sid: None,
name: None,
email: None,
section: None,
item_number: number,
item_ref: Some("bank::q-x-001".into()),
item_version: Some(2),
selected: vec!["C".into()],
eliminated: vec![],
correct: Some(true),
credit: 1.0,
points_possible: 1.5,
score: 1.5,
response_time_seconds: None,
level: None,
learning_objectives: vec!["lo-a".into()],
topics: vec![],
bonus: false,
dropped: false,
}
}
#[test]
fn sanitizes_administration_ids() {
assert_eq!(sanitize("BIOSC1540/2026s/exam-4"), "biosc1540_2026s_exam-4");
assert_eq!(sanitize("///"), "responses");
// Runs of separators collapse rather than stacking underscores.
assert_eq!(sanitize("a // b"), "a_b");
}
#[test]
fn csv_round_trips_through_the_store() {
let dir = std::env::temp_dir().join(format!("cb-store-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
let store = Store::open(&dir).unwrap().with_format(Format::Csv).unwrap();
let mut set = ResponseSet::new();
set.rows.push(row("s1", 1));
set.rows.push(row("s2", 1));
let written = store.write(&set).unwrap();
assert_eq!(written.len(), 1);
assert!(written[0].exists());
let back = store.read("BIOSC1540/2026s/exam-4").unwrap();
assert_eq!(back.rows.len(), 2);
assert_eq!(back.rows[0].item_ref.as_deref(), Some("bank::q-x-001"));
assert_eq!(back.rows[0].selected, vec!["C".to_string()]);
assert_eq!(back.rows[0].learning_objectives, vec!["lo-a".to_string()]);
let all = store.read_all().unwrap();
assert_eq!(all.rows.len(), 2);
let summaries = summarize(&store).unwrap();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].students, 2);
assert_eq!(summaries[0].items, 1);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn rewriting_replaces_rather_than_duplicates() {
let dir = std::env::temp_dir().join(format!("cb-store-rw-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
let store = Store::open(&dir).unwrap().with_format(Format::Csv).unwrap();
let mut set = ResponseSet::new();
set.rows.push(row("s1", 1));
store.write(&set).unwrap();
store.write(&set).unwrap();
assert_eq!(store.read_all().unwrap().rows.len(), 1, "no duplicates");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn missing_administrations_report_clearly() {
let dir = std::env::temp_dir().join(format!("cb-store-miss-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
let store = Store::open(&dir).unwrap().with_format(Format::Csv).unwrap();
let err = store.read("nope").unwrap_err();
assert!(err.to_string().contains("no stored responses"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn format_extensions_round_trip() {
assert_eq!(
Format::from_path(Path::new("a/b.parquet")),
Some(Format::Parquet)
);
assert_eq!(Format::from_path(Path::new("a/b.csv")), Some(Format::Csv));
assert_eq!(Format::from_path(Path::new("a/b.yaml")), None);
assert!(Format::Csv.is_available());
}
}
+391
View File
@@ -0,0 +1,391 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Parquet reading and writing, isolated so the rest of the crate never touches
//! Arrow types.
//!
//! Every use of `arrow` and `parquet` in this crate is in this file. That is
//! deliberate: those two crates move fast and release breaking versions together,
//! and confining them to one module means a version bump is a single-file edit
//! rather than a refactor. It also means `--no-default-features` compiles the
//! whole tool without them.
//!
//! The schema mirrors [`FlatResponse`] field for field. Optional values are
//! written with the same sentinels the CSV path uses — empty string, `0` for an
//! unknown level — rather than nulls, so that a Parquet file and a CSV file of
//! the same data load identically in pandas.
use std::path::Path;
use std::sync::Arc;
use arrow_array::{ArrayRef, BooleanArray, Float64Array, RecordBatch, StringArray, UInt32Array};
use arrow_schema::{DataType, Field, Schema};
use parquet::arrow::ArrowWriter;
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
use parquet::basic::Compression;
use parquet::file::properties::WriterProperties;
use crate::error::{Error, Result};
use crate::responses::FlatResponse;
/// The Arrow schema for stored responses.
///
/// # Returns
///
/// The schema, whose field order matches [`FlatResponse`].
pub fn schema() -> Schema {
Schema::new(vec![
Field::new("administration_id", DataType::Utf8, false),
Field::new("course", DataType::Utf8, false),
Field::new("term", DataType::Utf8, false),
Field::new("assessment_id", DataType::Utf8, false),
Field::new("date", DataType::Utf8, false),
Field::new("form", DataType::Utf8, false),
Field::new("student_key", DataType::Utf8, false),
Field::new("sid", DataType::Utf8, false),
Field::new("email", DataType::Utf8, false),
Field::new("section", DataType::Utf8, false),
Field::new("item_number", DataType::UInt32, false),
Field::new("item_ref", DataType::Utf8, false),
Field::new("item_version", DataType::UInt32, false),
Field::new("selected", DataType::Utf8, false),
Field::new("eliminated", DataType::Utf8, false),
Field::new("correct", DataType::Utf8, false),
Field::new("credit", DataType::Float64, false),
Field::new("points_possible", DataType::Float64, false),
Field::new("score", DataType::Float64, false),
Field::new("response_time_seconds", DataType::Utf8, false),
Field::new("level", DataType::UInt32, false),
Field::new("learning_objectives", DataType::Utf8, false),
Field::new("topics", DataType::Utf8, false),
Field::new("bonus", DataType::Boolean, false),
Field::new("dropped", DataType::Boolean, false),
])
}
/// Builds a record batch from flat responses.
///
/// # Arguments
///
/// * `rows` - the rows.
///
/// # Returns
///
/// The batch.
///
/// # Errors
///
/// Returns [`Error::Other`] when Arrow rejects the column set, which would mean
/// this function and [`schema`] have drifted apart.
fn to_batch(rows: &[FlatResponse]) -> Result<RecordBatch> {
let s = |f: fn(&FlatResponse) -> &str| -> ArrayRef {
Arc::new(StringArray::from(rows.iter().map(f).collect::<Vec<&str>>()))
};
let f64c = |f: fn(&FlatResponse) -> f64| -> ArrayRef {
Arc::new(Float64Array::from(rows.iter().map(f).collect::<Vec<f64>>()))
};
let u32c = |f: fn(&FlatResponse) -> u32| -> ArrayRef {
Arc::new(UInt32Array::from(rows.iter().map(f).collect::<Vec<u32>>()))
};
let boolc = |f: fn(&FlatResponse) -> bool| -> ArrayRef {
Arc::new(BooleanArray::from(
rows.iter().map(f).collect::<Vec<bool>>(),
))
};
let columns: Vec<ArrayRef> = vec![
s(|r| &r.administration_id),
s(|r| &r.course),
s(|r| &r.term),
s(|r| &r.assessment_id),
s(|r| &r.date),
s(|r| &r.form),
s(|r| &r.student_key),
s(|r| &r.sid),
s(|r| &r.email),
s(|r| &r.section),
u32c(|r| r.item_number),
s(|r| &r.item_ref),
u32c(|r| r.item_version),
s(|r| &r.selected),
s(|r| &r.eliminated),
s(|r| &r.correct),
f64c(|r| r.credit),
f64c(|r| r.points_possible),
f64c(|r| r.score),
s(|r| &r.response_time_seconds),
u32c(|r| r.level as u32),
s(|r| &r.learning_objectives),
s(|r| &r.topics),
boolc(|r| r.bonus),
boolc(|r| r.dropped),
];
RecordBatch::try_new(Arc::new(schema()), columns).map_err(|e| {
Error::Other(format!(
"internal error building a Parquet batch: {e}. This means the Arrow schema and the \
column builders in store_parquet.rs have drifted apart"
))
})
}
/// Writes flat responses to a Parquet file.
///
/// # Arguments
///
/// * `path` - the destination.
/// * `rows` - the rows.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure and [`Error::Other`] when the
/// Parquet writer rejects the batch.
pub fn write(path: &Path, rows: &[FlatResponse]) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
}
let batch = to_batch(rows)?;
let file = std::fs::File::create(path).map_err(|e| Error::io(path, e))?;
// Snappy rather than zstd: it is the format's most universally readable
// codec, and these files are small enough that the ratio difference is
// irrelevant next to being openable by an old pandas.
let props = WriterProperties::builder()
.set_compression(Compression::SNAPPY)
.build();
let mut writer = ArrowWriter::try_new(file, Arc::new(schema()), Some(props))
.map_err(|e| Error::Other(format!("cannot open {} for Parquet: {e}", path.display())))?;
writer
.write(&batch)
.map_err(|e| Error::Other(format!("cannot write {}: {e}", path.display())))?;
writer
.close()
.map_err(|e| Error::Other(format!("cannot finish {}: {e}", path.display())))?;
Ok(())
}
/// Reads flat responses from a Parquet file.
///
/// # Arguments
///
/// * `path` - the file.
///
/// # Returns
///
/// The rows.
///
/// # Errors
///
/// Returns [`Error::Io`] when the file cannot be opened and [`Error::Other`]
/// when its schema does not match what this crate writes.
pub fn read(path: &Path) -> Result<Vec<FlatResponse>> {
let file = std::fs::File::open(path).map_err(|e| Error::io(path, e))?;
let builder = ParquetRecordBatchReaderBuilder::try_new(file)
.map_err(|e| Error::Other(format!("cannot read {} as Parquet: {e}", path.display())))?;
let reader = builder
.build()
.map_err(|e| Error::Other(format!("cannot read {}: {e}", path.display())))?;
let mut out = Vec::new();
for batch in reader {
let batch =
batch.map_err(|e| Error::Other(format!("cannot read {}: {e}", path.display())))?;
out.extend(from_batch(&batch, path)?);
}
Ok(out)
}
/// Converts a record batch back into flat responses.
///
/// # Arguments
///
/// * `batch` - the batch.
/// * `path` - the source file, for error messages.
///
/// # Returns
///
/// The rows.
///
/// # Errors
///
/// Returns [`Error::Other`] when a column is missing or has the wrong type.
fn from_batch(batch: &RecordBatch, path: &Path) -> Result<Vec<FlatResponse>> {
let strings = |name: &str| -> Result<&StringArray> {
batch
.column_by_name(name)
.and_then(|c| c.as_any().downcast_ref::<StringArray>())
.ok_or_else(|| column_error(name, "string", path))
};
let floats = |name: &str| -> Result<&Float64Array> {
batch
.column_by_name(name)
.and_then(|c| c.as_any().downcast_ref::<Float64Array>())
.ok_or_else(|| column_error(name, "float64", path))
};
let uints = |name: &str| -> Result<&UInt32Array> {
batch
.column_by_name(name)
.and_then(|c| c.as_any().downcast_ref::<UInt32Array>())
.ok_or_else(|| column_error(name, "uint32", path))
};
let bools = |name: &str| -> Result<&BooleanArray> {
batch
.column_by_name(name)
.and_then(|c| c.as_any().downcast_ref::<BooleanArray>())
.ok_or_else(|| column_error(name, "boolean", path))
};
let administration_id = strings("administration_id")?;
let course = strings("course")?;
let term = strings("term")?;
let assessment_id = strings("assessment_id")?;
let date = strings("date")?;
let form = strings("form")?;
let student_key = strings("student_key")?;
let sid = strings("sid")?;
let email = strings("email")?;
let section = strings("section")?;
let item_number = uints("item_number")?;
let item_ref = strings("item_ref")?;
let item_version = uints("item_version")?;
let selected = strings("selected")?;
let eliminated = strings("eliminated")?;
let correct = strings("correct")?;
let credit = floats("credit")?;
let points_possible = floats("points_possible")?;
let score = floats("score")?;
let response_time_seconds = strings("response_time_seconds")?;
let level = uints("level")?;
let learning_objectives = strings("learning_objectives")?;
let topics = strings("topics")?;
let bonus = bools("bonus")?;
let dropped = bools("dropped")?;
let mut out = Vec::with_capacity(batch.num_rows());
for i in 0..batch.num_rows() {
out.push(FlatResponse {
administration_id: administration_id.value(i).to_string(),
course: course.value(i).to_string(),
term: term.value(i).to_string(),
assessment_id: assessment_id.value(i).to_string(),
date: date.value(i).to_string(),
form: form.value(i).to_string(),
student_key: student_key.value(i).to_string(),
sid: sid.value(i).to_string(),
email: email.value(i).to_string(),
section: section.value(i).to_string(),
item_number: item_number.value(i),
item_ref: item_ref.value(i).to_string(),
item_version: item_version.value(i),
selected: selected.value(i).to_string(),
eliminated: eliminated.value(i).to_string(),
correct: correct.value(i).to_string(),
credit: credit.value(i),
points_possible: points_possible.value(i),
score: score.value(i),
response_time_seconds: response_time_seconds.value(i).to_string(),
level: level.value(i) as u8,
learning_objectives: learning_objectives.value(i).to_string(),
topics: topics.value(i).to_string(),
bonus: bonus.value(i),
dropped: dropped.value(i),
});
}
Ok(out)
}
/// Builds the error for a missing or mistyped column.
fn column_error(name: &str, expected: &str, path: &Path) -> Error {
Error::Other(format!(
"{} is missing the `{name}` column or it is not {expected}; this file was probably not \
written by coursebank",
path.display()
))
}
#[cfg(test)]
mod tests {
use super::*;
fn flat(student: &str, number: u32) -> FlatResponse {
FlatResponse {
administration_id: "C/2026s/e1".into(),
course: "C".into(),
term: "2026s".into(),
assessment_id: "e1".into(),
date: "2026-04-01".into(),
form: String::new(),
student_key: student.into(),
sid: "1234567".into(),
email: String::new(),
section: "L01".into(),
item_number: number,
item_ref: "bank::q-a-001".into(),
item_version: 3,
selected: "C".into(),
eliminated: String::new(),
correct: "1".into(),
credit: 1.0,
points_possible: 1.5,
score: 1.5,
response_time_seconds: "42.0".into(),
level: 3,
learning_objectives: "lo-a,lo-b".into(),
topics: "kinetics".into(),
bonus: false,
dropped: false,
}
}
#[test]
fn schema_matches_the_column_builders() {
let rows = vec![flat("s1", 1)];
let batch = to_batch(&rows).expect("schema and builders agree");
assert_eq!(batch.num_columns(), schema().fields().len());
assert_eq!(batch.num_rows(), 1);
}
#[test]
fn round_trips_through_a_file() {
let dir = std::env::temp_dir().join(format!("cb-pq-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("e1.parquet");
let rows = vec![flat("s1", 1), flat("s2", 2)];
write(&path, &rows).unwrap();
let back = read(&path).unwrap();
assert_eq!(back.len(), 2);
assert_eq!(back[0].student_key, "s1");
assert_eq!(back[1].item_number, 2);
assert_eq!(back[0].learning_objectives, "lo-a,lo-b");
assert_eq!(back[0].credit, 1.0);
assert_eq!(back[0].level, 3);
assert!(!back[0].bonus);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_empty_batch_is_writable() {
let dir = std::env::temp_dir().join(format!("cb-pq-empty-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("empty.parquet");
write(&path, &[]).unwrap();
assert_eq!(read(&path).unwrap().len(), 0);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_foreign_file_is_rejected_clearly() {
let dir = std::env::temp_dir().join(format!("cb-pq-bad-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("not.parquet");
std::fs::write(&path, b"this is not parquet").unwrap();
let err = read(&path).unwrap_err();
assert!(err.to_string().contains("as Parquet"));
std::fs::remove_dir_all(&dir).ok();
}
}
+133
View File
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! One error type for the whole crate.
//!
//! Every fallible operation returns [`Error`]. Variants carry the path or
//! identifier at fault so a message is actionable without a backtrace. The
//! [`Error::Invalid`] variant carries a list of problems rather than one,
//! because validation is meant to report everything wrong with a file in a
//! single pass instead of making the author fix one issue per run.
use std::path::PathBuf;
/// The crate result alias.
pub type Result<T> = std::result::Result<T, Error>;
/// Anything that can go wrong loading, validating, or transforming course data.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// A file could not be read or written.
#[error("cannot read or write {path}: {source}")]
Io {
/// The offending path.
path: PathBuf,
/// The underlying I/O failure.
source: std::io::Error,
},
/// An I/O failure with no natural path to attach.
#[error("i/o error: {0}")]
BareIo(#[from] std::io::Error),
/// A YAML file did not match the schema.
#[error("{path} is not valid coursebank YAML: {source}")]
Yaml {
/// The offending path.
path: PathBuf,
/// The parse failure, which includes a line and column.
source: serde_yaml_ng::Error,
},
/// A JSON file did not parse.
#[error("{path} is not valid JSON: {source}")]
Json {
/// The offending path.
path: PathBuf,
/// The parse failure.
source: serde_json::Error,
},
/// A CSV file did not parse.
#[error("{path} is not valid CSV: {source}")]
Csv {
/// The offending path.
path: PathBuf,
/// The parse failure.
source: csv::Error,
},
/// A file was structurally fine but semantically wrong. Holds every problem
/// found so one run fixes one file.
#[error("{} problem(s) found:\n{}", .0.len(), format_issues(.0))]
Invalid(Vec<String>),
/// A reference did not resolve: an unknown objective, lecture, item, or bank.
#[error("unknown {kind} `{id}`{}", context_suffix(.context))]
Unresolved {
/// What kind of thing was referenced, e.g. `"learning objective"`.
kind: &'static str,
/// The identifier that did not resolve.
id: String,
/// Where the dangling reference was found, if known.
context: Option<String>,
},
/// The requested selection could not be satisfied from the available items.
#[error("cannot satisfy the blueprint: {0}")]
Infeasible(String),
/// A date string was not `YYYY-MM-DD`, or was not a real calendar date.
#[error("`{0}` is not a date in YYYY-MM-DD form")]
BadDate(String),
/// A command-line argument was well-formed but unusable.
#[error("{0}")]
Usage(String),
/// A capability was compiled out.
#[error("{0} support was not compiled in; rebuild with `--features {1}`")]
FeatureDisabled(&'static str, &'static str),
/// Something went wrong that does not deserve its own variant.
#[error("{0}")]
Other(String),
}
impl Error {
/// Wraps an I/O failure with the path that caused it.
pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Error {
Error::Io {
path: path.into(),
source,
}
}
/// Builds an [`Error::Other`] from anything displayable.
pub fn other(msg: impl std::fmt::Display) -> Error {
Error::Other(msg.to_string())
}
/// Builds an [`Error::Usage`] from anything displayable.
pub fn usage(msg: impl std::fmt::Display) -> Error {
Error::Usage(msg.to_string())
}
}
/// Renders an issue list as an indented bullet list.
fn format_issues(issues: &[String]) -> String {
issues
.iter()
.map(|i| format!(" - {i}"))
.collect::<Vec<_>>()
.join("\n")
}
/// Renders the optional context of an unresolved reference.
fn context_suffix(context: &Option<String>) -> String {
match context {
Some(c) => format!(" (referenced by {c})"),
None => String::new(),
}
}
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Turning course data into documents.
//!
//! | Module | Produces | For |
//! |:--|:--|:--|
//! | [`qti`] | a QTI 1.2 zip | importing into Canvas |
//! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet |
//! | [`report`] | Markdown and HTML | students, and yourself |
//!
//! [`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
//! paper. Both derive option order from the form's recorded seed rather than
//! storing it, so every export of form B agrees with every other.
//!
//! [`report`] writes two documents with different content, not different tones. The
//! student report answers "what should I do next?" and deliberately omits correct
//! answers, other students' data, and any numeric rank.
//! The instructor report answers "what should I fix?" and holds the item statistics.
pub mod qti;
pub mod report;
pub mod typst;
+690
View File
@@ -0,0 +1,690 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Exporting an assessment as a Canvas-importable QTI 1.2 package.
//!
//! QTI 1.2 is a fussy, half-abandoned standard, and Canvas reads a particular
//! dialect of it. Two details are worth recording because they are easy to get
//! wrong and produce silent misbehavior rather than an import error.
//!
//! First, question HTML lives inside `<mattext texttype="text/html">` as
//! character data, which means it is escaped once on the way in and unescaped
//! once by Canvas. So `&rarr;` is written as `&amp;rarr;`. Skipping that step
//! produces XML that parses and renders as literal `&rarr;` to students.
//!
//! Second, identifiers must be stable. Canvas keys re-imports and question banks
//! off them, so a package regenerated after a typo fix should carry the same ids
//! as the original. Every id here is derived by hashing the assessment id
//! together with the item's global id, never from a clock or a counter.
//!
//! Option order comes from the form, so exporting form A and form B of the same
//! assessment gives two packages that ask the same questions in different orders,
//! and the answer keys are guaranteed to agree with what was printed.
use crate::assessment::{AssessmentFile, Form, ScoringPolicy};
use crate::catalog::Catalog;
use crate::error::{Error, Result};
use crate::hash::{hex, sha256};
use crate::item::Item;
use crate::markup;
use crate::select;
use crate::taxonomy::Format;
use crate::zipfile::ZipBuilder;
/// The QTI 1.2 namespace.
const QTI_NS: &str = "http://www.imsglobal.org/xsd/ims_qtiasiv1p2";
/// The schema location Canvas expects alongside it.
const QTI_SCHEMA: &str = "http://www.imsglobal.org/xsd/ims_qtiasiv1p2 \
http://www.imsglobal.org/xsd/ims_qtiasiv1p2p1.xsd";
/// The XML Schema instance namespace.
const XSI_NS: &str = "http://www.w3.org/2001/XMLSchema-instance";
/// The IMS content packaging namespace.
const IMSCP_NS: &str = "http://www.imsglobal.org/xsd/imscp_v1p1";
/// The IMS metadata namespace.
const IMSMD_NS: &str = "http://www.imsglobal.org/xsd/imsmd_v1p2";
/// The content packaging schema location.
const IMSCP_SCHEMA: &str = "http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd \
http://www.imsglobal.org/xsd/imsmd_v1p2 imsmd_v1p2p2.xsd";
// ---------------------------------------------------------------------------
// A very small XML tree
// ---------------------------------------------------------------------------
/// One XML element.
#[derive(Debug, Clone)]
struct Node {
tag: String,
attrs: Vec<(String, String)>,
text: Option<String>,
children: Vec<Node>,
}
impl Node {
/// Creates an element with no attributes, text, or children.
fn new(tag: &str) -> Node {
Node {
tag: tag.to_string(),
attrs: Vec::new(),
text: None,
children: Vec::new(),
}
}
/// Adds an attribute, returning self for chaining.
fn attr(mut self, k: &str, v: impl Into<String>) -> Node {
self.attrs.push((k.to_string(), v.into()));
self
}
/// Sets the element text, returning self for chaining.
fn text(mut self, t: impl Into<String>) -> Node {
self.text = Some(t.into());
self
}
/// Appends a child, returning self for chaining.
fn child(mut self, c: Node) -> Node {
self.children.push(c);
self
}
/// Appends several children, returning self for chaining.
fn children(mut self, cs: Vec<Node>) -> Node {
self.children.extend(cs);
self
}
/// Renders the element and its subtree.
///
/// # Arguments
///
/// * `depth` - the indentation level.
///
/// # Returns
///
/// Indented XML, newline-terminated.
fn render(&self, depth: usize) -> String {
let pad = " ".repeat(depth);
let mut attrs = String::new();
for (k, v) in &self.attrs {
attrs.push_str(&format!(" {k}=\"{}\"", escape_attr(v)));
}
if self.children.is_empty() {
match &self.text {
None => format!("{pad}<{}{attrs}/>\n", self.tag),
Some(t) => format!(
"{pad}<{}{attrs}>{}</{}>\n",
self.tag,
escape_text(t),
self.tag
),
}
} else {
let mut out = format!("{pad}<{}{attrs}>\n", self.tag);
if let Some(t) = &self.text {
out.push_str(&format!("{pad} {}\n", escape_text(t)));
}
for c in &self.children {
out.push_str(&c.render(depth + 1));
}
out.push_str(&format!("{pad}</{}>\n", self.tag));
out
}
}
/// Renders a complete document with an XML declaration.
fn document(&self) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{}",
self.render(0)
)
}
}
/// Escapes text content.
fn escape_text(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// Escapes an attribute value.
fn escape_attr(s: &str) -> String {
escape_text(s).replace('"', "&quot;")
}
// ---------------------------------------------------------------------------
// Package construction
// ---------------------------------------------------------------------------
/// Options for a QTI export.
#[derive(Debug, Clone)]
pub struct QtiOptions {
/// Which form's option order to use.
pub form: Form,
/// Whether to include per-option feedback. Turn it off for a practice quiz
/// you intend to reuse as a graded one, since Canvas shows this feedback
/// immediately.
pub include_feedback: bool,
/// Whether to let Canvas shuffle answers on top of the form's own order.
pub shuffle_in_canvas: bool,
/// Maximum attempts; `-1` for unlimited.
pub attempts: i64,
/// How repeated attempts are scored.
pub scoring_policy: ScoringPolicy,
}
impl Default for QtiOptions {
fn default() -> QtiOptions {
QtiOptions {
form: Form {
id: "A".to_string(),
seed: 0,
shuffle_items: false,
shuffle_options: false,
},
include_feedback: true,
shuffle_in_canvas: false,
attempts: 1,
scoring_policy: ScoringPolicy::KeepHighest,
}
}
}
/// A rendered QTI package, ready to write.
#[derive(Debug, Clone)]
pub struct Package {
/// The name of the quiz XML file inside the archive.
pub quiz_filename: String,
/// The quiz XML.
pub quiz_xml: String,
/// The manifest XML.
pub manifest_xml: String,
}
impl Package {
/// Writes the package as a Canvas-importable zip.
///
/// # Arguments
///
/// * `path` - the destination `.zip` path.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure.
pub fn write_zip(&self, path: &std::path::Path) -> Result<()> {
let mut z = ZipBuilder::new();
// Canvas only recognizes the archive as QTI when the manifest sits at the
// root rather than inside a directory.
z.add_text("imsmanifest.xml", &self.manifest_xml);
z.add_text(&self.quiz_filename, &self.quiz_xml);
z.write_to(path)
}
}
/// Builds a QTI package for an assessment.
///
/// # Arguments
///
/// * `catalog` - the loaded course, for resolving items.
/// * `record` - the assessment record.
/// * `opts` - export options.
///
/// # Returns
///
/// The rendered package.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when a placement references a missing item, and
/// [`Error::Invalid`] when an item cannot be represented in QTI, such as one with
/// no keyed option.
pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> Result<Package> {
let assessment_id = qti_id(&format!("{}/assessment", record.assessment.id));
let default_points = catalog.course.policy.points_per_item;
let mut problems = Vec::new();
let mut items = Vec::new();
for placement in select::layout(record, &opts.form) {
let entry = catalog.require(&placement.item)?;
let item = &entry.item;
if item.key_indices().is_empty() {
problems.push(format!(
"question {} ({}) has no keyed option, so Canvas cannot score it",
placement.number, placement.item
));
continue;
}
let points = placement
.points
.unwrap_or_else(|| item.points(default_points));
items.push(build_item(
&record.assessment.id,
&placement.item,
item,
points,
opts,
));
}
if !problems.is_empty() {
return Err(Error::Invalid(problems));
}
let metadata = vec![
metadata_field("cc_maxattempts", &opts.attempts.to_string()),
metadata_field("cc_quiz_scoring_policy", opts.scoring_policy.as_str()),
metadata_field(
"cc_shuffle_answers",
if opts.shuffle_in_canvas {
"true"
} else {
"false"
},
),
];
let title = if record.forms.len() > 1 {
format!("{} (form {})", record.assessment.title, opts.form.id)
} else {
record.assessment.title.clone()
};
let assessment = Node::new("assessment")
.attr("ident", assessment_id.clone())
.attr("title", title.clone())
.child(Node::new("qtimetadata").children(metadata))
.child(
Node::new("section")
.attr("ident", "root_section")
.children(items),
);
let root = Node::new("questestinterop")
.attr("xmlns", QTI_NS)
.attr("xmlns:xsi", XSI_NS)
.attr("xsi:schemaLocation", QTI_SCHEMA)
.child(assessment);
let quiz_filename = format!("{}.xml", slug_filename(&record.assessment.id));
let manifest = build_manifest(
&quiz_filename,
&assessment_id,
&title,
&record.assessment.id,
);
Ok(Package {
quiz_filename,
quiz_xml: root.document(),
manifest_xml: manifest.document(),
})
}
/// Builds one `<item>` element.
///
/// # Arguments
///
/// * `assessment_id` - salts the generated ids.
/// * `uid` - the item's global id.
/// * `item` - the item.
/// * `points` - points as administered.
/// * `opts` - export options.
///
/// # Returns
///
/// The element.
fn build_item(assessment_id: &str, uid: &str, item: &Item, points: f64, opts: &QtiOptions) -> Node {
let order = select::option_order(&opts.form, uid, item.options.len());
let ordered: Vec<&crate::item::Choice> = order.iter().map(|i| &item.options[*i]).collect();
// Option identifiers are numeric, mirroring Canvas's own exports, and are
// derived from the item id so they survive regeneration.
let opt_ids: Vec<String> = ordered
.iter()
.map(|o| short_id(&format!("{assessment_id}/{uid}/{}", o.id)))
.collect();
let item_meta = Node::new("itemmetadata").child(Node::new("qtimetadata").children(vec![
metadata_field("question_type", item.format.qti_type()),
metadata_field("points_possible", &format!("{points:.2}")),
metadata_field("original_answer_ids", &opt_ids.join(",")),
metadata_field("assessment_question_identifierref", &qti_id(uid)),
]));
let cardinality = if item.format == Format::MultipleResponse {
"Multiple"
} else {
"Single"
};
let labels: Vec<Node> = ordered
.iter()
.zip(opt_ids.iter())
.map(|(o, id)| {
Node::new("response_label")
.attr("ident", id.clone())
.child(mattext(&markup::to_html(&o.text)))
})
.collect();
let presentation = Node::new("presentation")
.child(mattext(&format!(
"<div>{}</div>",
markup::to_html(&item.stem)
)))
.child(
Node::new("response_lid")
.attr("ident", "response1")
.attr("rcardinality", cardinality)
.child(Node::new("render_choice").children(labels)),
);
// --- response processing ---
let mut resprocessing = Node::new("resprocessing").child(
Node::new("outcomes").child(
Node::new("decvar")
.attr("maxvalue", "100")
.attr("minvalue", "0")
.attr("varname", "SCORE")
.attr("vartype", "Decimal"),
),
);
// A pass-through condition per option, so choosing anything triggers its
// feedback. `continue="Yes"` is what allows scoring to be evaluated after.
if opts.include_feedback {
for (o, id) in ordered.iter().zip(opt_ids.iter()) {
if o.student_text().is_none() {
continue;
}
resprocessing = resprocessing.child(
Node::new("respcondition")
.attr("continue", "Yes")
.child(
Node::new("conditionvar").child(
Node::new("varequal")
.attr("respident", "response1")
.text(id.clone()),
),
)
.child(
Node::new("displayfeedback")
.attr("feedbacktype", "Response")
.attr("linkrefid", format!("{id}_fb")),
),
);
}
}
let correct: Vec<&String> = ordered
.iter()
.zip(opt_ids.iter())
.filter(|(o, _)| o.correct)
.map(|(_, id)| id)
.collect();
let incorrect: Vec<&String> = ordered
.iter()
.zip(opt_ids.iter())
.filter(|(o, _)| !o.correct)
.map(|(_, id)| id)
.collect();
let condition = if item.format == Format::MultipleResponse {
// Full credit only for the exact set: every keyed option chosen and no
// unkeyed one. Without the negations, checking every box scores 100.
let mut and = Node::new("and");
for id in &correct {
and = and.child(
Node::new("varequal")
.attr("respident", "response1")
.text((*id).clone()),
);
}
for id in &incorrect {
and = and.child(
Node::new("not").child(
Node::new("varequal")
.attr("respident", "response1")
.text((*id).clone()),
),
);
}
Node::new("conditionvar").child(and)
} else {
Node::new("conditionvar").child(
Node::new("varequal")
.attr("respident", "response1")
.text(correct.first().map(|s| (*s).clone()).unwrap_or_default()),
)
};
resprocessing = resprocessing.child(
Node::new("respcondition")
.attr("continue", "No")
.child(condition)
.child(
Node::new("setvar")
.attr("action", "Set")
.attr("varname", "SCORE")
.text("100"),
),
);
let mut node = Node::new("item")
.attr("ident", qti_id(&format!("{assessment_id}/{uid}")))
.attr("title", item.display_title())
.child(item_meta)
.child(presentation)
.child(resprocessing);
if opts.include_feedback {
for (o, id) in ordered.iter().zip(opt_ids.iter()) {
if let Some(text) = o.student_text() {
node = node.child(
Node::new("itemfeedback")
.attr("ident", format!("{id}_fb"))
.child(
Node::new("flow_mat")
.child(mattext(&format!("<div>{}</div>", markup::to_html(text)))),
),
);
}
}
}
node
}
/// A `<material><mattext texttype="text/html">` pair.
///
/// # Arguments
///
/// * `html` - the HTML fragment, which is escaped on the way in.
///
/// # Returns
///
/// The element.
fn mattext(html: &str) -> Node {
Node::new("material").child(
Node::new("mattext")
.attr("texttype", "text/html")
.text(html),
)
}
/// A `<qtimetadatafield>` pair.
fn metadata_field(label: &str, entry: &str) -> Node {
Node::new("qtimetadatafield")
.child(Node::new("fieldlabel").text(label))
.child(Node::new("fieldentry").text(entry))
}
/// Builds the IMS content package manifest.
///
/// # Arguments
///
/// * `quiz_filename` - the quiz XML file name.
/// * `assessment_id` - the assessment identifier, reused as the resource id.
/// * `title` - the human title.
/// * `salt` - salts the manifest identifier.
///
/// # Returns
///
/// The manifest element.
fn build_manifest(quiz_filename: &str, assessment_id: &str, title: &str, salt: &str) -> Node {
let lom = Node::new("imsmd:lom").child(
Node::new("imsmd:general").child(
Node::new("imsmd:title").child(
Node::new("imsmd:langstring")
.attr("xml:lang", "en-US")
.text(title),
),
),
);
Node::new("manifest")
.attr("identifier", format!("man{}", qti_id(salt)))
.attr("xmlns", IMSCP_NS)
.attr("xmlns:imsmd", IMSMD_NS)
.attr("xmlns:xsi", XSI_NS)
.attr("xsi:schemaLocation", IMSCP_SCHEMA)
.child(
Node::new("metadata")
.child(Node::new("schema").text("IMS Content"))
.child(Node::new("schemaversion").text("1.1.3"))
.child(lom),
)
.child(
Node::new("organizations").attr("default", "root").child(
Node::new("organization")
.attr("identifier", "root")
.attr("structure", "rooted"),
),
)
.child(
Node::new("resources").child(
Node::new("resource")
.attr("identifier", assessment_id)
.attr("type", "imsqti_xmlv1p2")
.attr("href", quiz_filename)
.child(Node::new("file").attr("href", quiz_filename)),
),
)
}
/// A deterministic QTI identifier: `g` followed by 32 hex characters.
///
/// # Arguments
///
/// * `key` - the stable string to derive from.
///
/// # Returns
///
/// The identifier.
fn qti_id(key: &str) -> String {
let digest = hex(&sha256(key.as_bytes()));
format!("g{}", &digest[..32])
}
/// A deterministic short numeric identifier, as Canvas uses for answers.
///
/// # Arguments
///
/// * `key` - the stable string to derive from.
///
/// # Returns
///
/// A four-digit numeric string.
fn short_id(key: &str) -> String {
let d = sha256(key.as_bytes());
let n = u32::from_be_bytes([d[0], d[1], d[2], d[3]]);
// 1000..9999 keeps the width fixed, which some Canvas importers prefer.
format!("{}", 1000 + (n % 9000))
}
/// Makes a file-name-safe slug.
fn slug_filename(s: &str) -> String {
let mut out = String::new();
for ch in s.chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
out.push(ch);
} else {
out.push('_');
}
}
if out.is_empty() {
"quiz".to_string()
} else {
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escapes_html_once_inside_mattext() {
let n = mattext("<p>a &rarr; b</p>");
let xml = n.render(0);
// The HTML is character data, so its markup is escaped.
assert!(xml.contains("&lt;p&gt;a &amp;rarr; b&lt;/p&gt;"), "{xml}");
assert!(!xml.contains("<p>"));
}
#[test]
fn attributes_are_escaped() {
let xml = Node::new("item")
.attr("title", "a \"quoted\" & <angled>")
.render(0);
assert!(xml.contains("&quot;quoted&quot;"));
assert!(xml.contains("&amp;"));
assert!(!xml.contains("<angled>"));
}
#[test]
fn ids_are_deterministic_and_well_formed() {
assert_eq!(qti_id("a"), qti_id("a"));
assert_ne!(qti_id("a"), qti_id("b"));
let id = qti_id("exam-4/assessment");
assert_eq!(id.len(), 33);
assert!(id.starts_with('g'));
assert!(id[1..].chars().all(|c| c.is_ascii_hexdigit()));
let s = short_id("x");
assert_eq!(s.len(), 4);
assert!(s.parse::<u32>().unwrap() >= 1000);
}
#[test]
fn empty_element_renders_self_closing() {
assert_eq!(
Node::new("file").attr("href", "q.xml").render(0),
"<file href=\"q.xml\"/>\n"
);
}
#[test]
fn document_has_a_declaration() {
let doc = Node::new("root").document();
assert!(doc.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"));
}
#[test]
fn manifest_points_at_the_quiz_file() {
let m = build_manifest("exam-4.xml", "gabc", "Exam 4", "exam-4").render(0);
assert!(m.contains("imsqti_xmlv1p2"));
assert!(m.contains("href=\"exam-4.xml\""));
assert!(m.contains("Exam 4"));
}
#[test]
fn slug_filename_is_safe() {
assert_eq!(slug_filename("exam-4 2026s"), "exam-4_2026s");
assert_eq!(slug_filename(""), "quiz");
}
}
+1084
View File
File diff suppressed because it is too large Load Diff
+452
View File
@@ -0,0 +1,452 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Rendering a printed exam with Typst.
//!
//! Typst rather than LaTeX because the toolchain is one binary with no package
//! manager, the error messages point at a line, and the compile is fast enough to
//! iterate on. `pixi run -e docs typst compile` turns the output of this module
//! into a PDF.
//!
//! ## What changed, and why
//!
//! This module used to build a document with `format!`. That made the position of
//! the points label a Rust change, put a `#grid` call in a match arm, and meant
//! the only way to move something on the page was to fork the crate. It also made
//! a generated exam a dead end: you could edit the output, but the next export
//! overwrote the edit.
//!
//! So the tool no longer writes documents. It loads a Typst file that you own,
//! finds the markers in it, and injects data:
//!
//! ```typst
//! // coursebank:begin questions
//! #render-question((number: 1, stem: [Sample.], options: ()))
//! // coursebank:end questions
//! ```
//!
//! `render-question` is defined in your template. What the payload contains is
//! governed by [`config::RenderConfig`]; where it lands and how it looks is
//! governed by the template. The default templates are compiled into the binary,
//! and `coursebank template dump` writes them into `templates/` so that
//! customizing means editing a file. See [`template`] for the marker syntax, the
//! slot list, and the lookup order.
//!
//! ## What is still enforced here
//!
//! Two invariants survived the rewrite, because both are the kind of mistake a
//! room full of students discovers simultaneously.
//!
//! **A form's key is derived from the same permutation as its paper.** Option order
//! comes from [`select::option_order`](crate::select::option_order) against the
//! form's recorded seed, never from anything stored, so every export of form B
//! agrees with every other. The paper, the key, and the answer sheet are all built
//! from one [`payload::Payload`].
//!
//! **The paper's payload does not contain the answer.** Not `correct: false`, not a
//! flag to check — the field is absent. See [`config::Reveal`]. A template cannot
//! leak what it was never given, and that stays true through every future edit of
//! the template by someone who has not read this comment.
pub mod config;
pub mod payload;
pub mod template;
pub mod value;
use std::path::{Path, PathBuf};
pub use config::{
CONFIG_FILE, CONFIG_TEMPLATE, ConfigFile, ContentMode, Fields, LetterStyle, Overrides,
RenderConfig, Reveal, StimulusMode, Variant,
};
pub use payload::Payload;
pub use template::{Origin, Slot, Template};
pub use value::Value;
use crate::Layout;
use crate::assessment::{AssessmentFile, Form};
use crate::catalog::Catalog;
use crate::error::{Error, Result};
/// What to render.
#[derive(Debug, Clone)]
pub struct Options {
/// Which form.
pub form: Form,
/// Which document.
pub variant: Variant,
/// A template path that overrides the usual lookup. Takes precedence over
/// `template` in the render config.
pub template: Option<PathBuf>,
/// What to put in the payload, and how.
pub config: RenderConfig,
}
impl Default for Options {
fn default() -> Options {
Options {
form: Form {
id: "A".to_string(),
seed: 0,
shuffle_items: false,
shuffle_options: false,
},
variant: Variant::Exam,
template: None,
config: RenderConfig::for_variant(Variant::Exam),
}
}
}
impl Options {
/// Options for one variant, with that variant's default configuration.
///
/// # Arguments
///
/// * `variant` - which document.
/// * `form` - the form to render.
///
/// # Returns
///
/// The options.
pub fn new(variant: Variant, form: Form) -> Options {
Options {
form,
variant,
template: None,
config: RenderConfig::for_variant(variant),
}
}
/// The template path to use, preferring the explicit one.
fn template_path(&self) -> Option<&Path> {
self.template.as_deref().or(self.config.template.as_deref())
}
}
/// A finished document, with everything a caller needs to explain it.
#[derive(Debug, Clone)]
pub struct Rendered {
/// Which document this is.
pub variant: Variant,
/// Where the template came from.
pub origin: Origin,
/// Which slots the template declared and this render filled.
pub slots: Vec<Slot>,
/// The Typst source.
pub text: String,
/// The payload, kept so a caller can also write it as JSON without rebuilding.
pub payload: Payload,
/// Advisory problems: markup that will probably confuse the Typst compiler, and
/// a template that declared no markers at all.
pub warnings: Vec<String>,
}
/// Renders one document.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `opts` - what to render.
///
/// # Returns
///
/// The finished document.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when a placement references a missing item,
/// [`Error::Io`] when an explicitly requested template cannot be read, and
/// [`Error::Invalid`] when the template's markers are malformed or every placement
/// is marked dropped.
pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<Rendered> {
let template = template::load(
&catalog.layout,
opts.variant,
Some(record.assessment.id.as_str()),
opts.template_path(),
)?;
let payload = payload::build(catalog, record, &opts.form, &opts.config)?;
if payload.questions.is_empty() {
return Err(Error::Invalid(vec![
"this assessment has no printable items; every placement is marked dropped".to_string(),
]));
}
// Only build what the template asked for. A paper template that never mentions
// `data` should not pay for a second copy of every stem.
let mut bodies = Vec::new();
if template.wants(Slot::Meta) {
bodies.push((Slot::Meta, payload::meta_body(&payload, &opts.config)));
}
if template.wants(Slot::Questions) {
bodies.push((
Slot::Questions,
payload::questions_body(&payload, &opts.config),
));
}
if template.wants(Slot::Data) {
bodies.push((Slot::Data, payload::data_body(&payload, &opts.config)));
}
let mut warnings = payload::check(&payload, &opts.config);
if template.is_inert() {
warnings.push(format!(
"the template {} declares no coursebank markers, so no questions were injected; add \
`// coursebank:questions` where they belong",
template.origin
));
}
Ok(Rendered {
variant: opts.variant,
origin: template.origin.clone(),
slots: template.slots(),
text: template.render(&bodies),
payload,
warnings,
})
}
/// Builds the payload without rendering a template.
///
/// For writing the questions out as JSON, which is what a hand-written Typst exam
/// that already calls `json("questions.json")` wants.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `opts` - what to render; only the form and config are consulted.
///
/// # Returns
///
/// The payload.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when a placement references a missing item.
pub fn build_payload(
catalog: &Catalog,
record: &AssessmentFile,
opts: &Options,
) -> Result<Payload> {
payload::build(catalog, record, &opts.form, &opts.config)
}
/// The path a course's Typst configuration lives at.
///
/// # Arguments
///
/// * `layout` - the course layout.
pub fn config_path(layout: &Layout) -> PathBuf {
template::dir(layout).join(CONFIG_FILE)
}
/// Loads a course's Typst configuration.
///
/// # Arguments
///
/// * `layout` - the course layout.
///
/// # Returns
///
/// The parsed config file, or an empty one when `templates/typst.yaml` is absent.
/// Absence is not an error: a course that has never customized anything should
/// export without being told to write a config file first.
///
/// # Errors
///
/// Returns [`Error::Yaml`] when the file exists but does not parse.
pub fn load_config(layout: &Layout) -> Result<ConfigFile> {
let path = config_path(layout);
if path.is_file() {
crate::yaml::read(&path)
} else {
Ok(ConfigFile::default())
}
}
/// Renders the question paper.
///
/// Kept so existing callers keep working. New code should use [`render`], which
/// also reports which template was used and what it warned about.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `opts` - rendering options; the variant is forced to [`Variant::Exam`].
///
/// # Returns
///
/// A complete Typst document.
///
/// # Errors
///
/// As [`render`].
pub fn exam(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<String> {
render_variant(catalog, record, opts, Variant::Exam)
}
/// Renders the answer key.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `opts` - rendering options; the variant is forced to [`Variant::Key`].
///
/// # Returns
///
/// A complete Typst document.
///
/// # Errors
///
/// As [`render`].
pub fn answer_key(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<String> {
render_variant(catalog, record, opts, Variant::Key)
}
/// Renders a bubble sheet matching the form.
///
/// # Arguments
///
/// * `catalog` - the loaded course.
/// * `record` - the assessment record.
/// * `opts` - rendering options; the variant is forced to [`Variant::AnswerSheet`].
///
/// # Returns
///
/// A complete Typst document.
///
/// # Errors
///
/// As [`render`].
pub fn bubble_sheet(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Result<String> {
render_variant(catalog, record, opts, Variant::AnswerSheet)
}
/// Renders one variant, substituting that variant's default config when the caller
/// passed the config for a different one.
fn render_variant(
catalog: &Catalog,
record: &AssessmentFile,
opts: &Options,
variant: Variant,
) -> Result<String> {
let mut opts = opts.clone();
if opts.variant != variant {
// The caller asked for a different document than the config describes, so
// the config's `reveal` almost certainly belongs to the other one. Taking
// the variant's own default is the safe reading, and the direction that
// matters is the paper: never inherit a key's `reveal`.
opts.config = RenderConfig::for_variant(variant);
opts.variant = variant;
}
Ok(render(catalog, record, &opts)?.text)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assessment::Placement;
use crate::select;
#[test]
fn options_default_to_the_paper_and_withhold_the_key() {
let opts = Options::default();
assert_eq!(opts.variant, Variant::Exam);
assert!(!opts.config.reveal.shows_key());
}
#[test]
fn an_explicit_template_beats_the_config() {
let mut opts = Options::default();
opts.config.template = Some(PathBuf::from("from-config.typ"));
assert_eq!(opts.template_path(), Some(Path::new("from-config.typ")));
opts.template = Some(PathBuf::from("from-cli.typ"));
assert_eq!(opts.template_path(), Some(Path::new("from-cli.typ")));
}
#[test]
fn the_key_reports_letters_as_printed() {
// Shuffling must relabel the key: if the correct option moves to the third
// printed position, the key says C.
let form = Form {
id: "B".into(),
seed: 99,
shuffle_items: false,
shuffle_options: true,
};
let order = select::option_order(&form, "bank::q-1", 4);
let correct_source = 0usize;
let printed_position = order.iter().position(|i| *i == correct_source).unwrap();
let letter = LetterStyle::Upper.label(printed_position);
assert!(["A", "B", "C", "D"].contains(&letter.as_str()));
// And it is reproducible.
let again = select::option_order(&form, "bank::q-1", 4);
assert_eq!(order, again);
}
#[test]
fn dropped_items_are_not_printed() {
let record = AssessmentFile {
schema_version: "1.0".into(),
assessment: crate::assessment::Assessment {
id: "e1".into(),
title: "Exam 1".into(),
term: None,
kind: crate::assessment::Kind::Exam,
date: None,
platform: crate::assessment::Platform::Paper,
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: "b::q-1".into(),
version: None,
fingerprint: None,
points: None,
bonus: false,
key: vec!["A".into()],
level: None,
learning_objectives: Vec::new(),
credit_overrides: Default::default(),
dropped: true,
},
Placement {
number: 2,
item: "b::q-2".into(),
version: None,
fingerprint: None,
points: None,
bonus: false,
key: vec!["B".into()],
level: None,
learning_objectives: Vec::new(),
credit_overrides: Default::default(),
dropped: false,
},
],
};
let printable: Vec<u32> = select::layout(&record, &Options::default().form)
.into_iter()
.filter(|p| !p.dropped)
.map(|p| p.number)
.collect();
assert_eq!(printable, vec![2]);
}
}
+750
View File
@@ -0,0 +1,750 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! What gets emitted, and in what shape.
//!
//! This module holds the knobs that used to be `format!` calls. The split is
//! deliberate: a template decides how a question *looks*, and this config decides
//! what the template is *told*. Anything you can express by rearranging boxes
//! belongs in the template, not here.
//!
//! The one setting that is not cosmetic is [`Reveal`]. It controls whether the
//! payload contains the answer at all, and the reason it is a config value rather
//! than a template concern is that a template cannot be trusted with it. If the
//! exam paper's payload carries `correct: true`, then every future edit to that
//! template is one `if` statement away from printing the key, and the failure mode
//! is discovered by a room full of students. Withholding the field is the only
//! version of this that stays correct under editing.
//!
//! Config is resolved in three layers, each overriding the last: the built-in
//! defaults for the variant, the `defaults:` block of `templates/typst.yaml`, and
//! that file's `variants:` block. `coursebank template config` writes the whole
//! resolved thing out so there is no guessing about what applied.
use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
/// Which document is being produced.
///
/// A variant is not a style. It is a different set of facts: the paper withholds
/// the key, the key withholds the questions, and the answer sheet needs only the
/// option counts. Each has its own template and its own default [`Reveal`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Variant {
/// The question paper a student writes on.
Exam,
/// The grader's answer key.
Key,
/// A bubble sheet matching the form.
AnswerSheet,
}
impl Variant {
/// Every variant, in the order `export` writes them.
pub const ALL: [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 {
match self {
Variant::Exam => "exam",
Variant::Key => "key",
Variant::AnswerSheet => "answer-sheet",
}
}
/// The variant for a token.
///
/// # Arguments
///
/// * `s` - the token, e.g. `"answer-sheet"`.
///
/// # Returns
///
/// The variant.
///
/// # Errors
///
/// Returns [`Error::Usage`] naming the valid tokens.
pub fn parse(s: &str) -> Result<Variant> {
let normalized = s.trim().to_ascii_lowercase().replace('_', "-");
Variant::ALL
.iter()
.copied()
.find(|v| v.as_str() == normalized)
.ok_or_else(|| {
Error::usage(format!(
"unknown template variant `{s}`; expected one of {}",
Variant::ALL
.iter()
.map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", ")
))
})
}
/// The file name this variant's template is looked up under.
pub fn template_file(self) -> String {
format!("{}.typ", self.as_str())
}
/// The suffix appended to an exported file's stem.
///
/// The paper gets no suffix because it is the thing you print most often and
/// `exam-2-A.typ` reads better than `exam-2-A-exam.typ`.
pub fn suffix(self) -> &'static str {
match self {
Variant::Exam => "",
Variant::Key => "-key",
Variant::AnswerSheet => "-answer-sheet",
}
}
}
/// How much of the answer side of an item reaches the payload.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Reveal {
/// Nothing. No `correct`, no `credit`, no keyed letters, no rationale. What a
/// student form must use.
Nothing,
/// Which options are correct, what credit each earns, and any recorded
/// partial-credit overrides. Enough to grade with.
Key,
/// Everything, including instructor rationale, targeted misconceptions, and
/// the record's private notes. For a review copy that never leaves your desk.
Everything,
}
impl Reveal {
/// Whether keyed letters, `correct`, and `credit` are emitted.
pub fn shows_key(self) -> bool {
self != Reveal::Nothing
}
/// Whether rationale, misconceptions, and private notes are emitted.
pub fn shows_rationale(self) -> bool {
self == Reveal::Everything
}
}
/// How option labels are generated.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LetterStyle {
/// `A`, `B`, `C`.
Upper,
/// `a`, `b`, `c`.
Lower,
/// `1`, `2`, `3`.
Numeric,
/// `i`, `ii`, `iii`.
Roman,
/// No label; the template supplies its own, usually via `numbering`.
Nothing,
}
impl LetterStyle {
/// The label for a zero-based printed position.
///
/// # Arguments
///
/// * `position` - the printed position, counting from zero.
///
/// # Returns
///
/// The label, or an empty string for [`LetterStyle::Nothing`].
pub fn label(self, position: usize) -> String {
match self {
LetterStyle::Upper => alphabetic(position, true),
LetterStyle::Lower => alphabetic(position, false),
LetterStyle::Numeric => (position + 1).to_string(),
LetterStyle::Roman => roman(position + 1),
LetterStyle::Nothing => String::new(),
}
}
}
/// A spreadsheet-style label: `A`..`Z`, then `AA`.
///
/// Eight options is the schema's ceiling, so the second character is unreachable
/// in practice. It is here so that raising that ceiling does not silently produce
/// `[` as an option label, which is what `b'A' + 26` gives you.
fn alphabetic(position: usize, upper: bool) -> String {
let base = if upper { b'A' } else { b'a' };
let mut n = position;
let mut letters = Vec::new();
loop {
letters.push((base + (n % 26) as u8) as char);
if n < 26 {
break;
}
n = n / 26 - 1;
}
letters.iter().rev().collect()
}
/// A lowercase Roman numeral for a one-based position.
fn roman(mut n: usize) -> String {
const TABLE: [(usize, &str); 13] = [
(1000, "m"),
(900, "cm"),
(500, "d"),
(400, "cd"),
(100, "c"),
(90, "xc"),
(50, "l"),
(40, "xl"),
(10, "x"),
(9, "ix"),
(5, "v"),
(4, "iv"),
(1, "i"),
];
let mut out = String::new();
for (value, numeral) in TABLE {
while n >= value {
out.push_str(numeral);
n -= value;
}
}
out
}
/// How markup-bearing fields are emitted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ContentMode {
/// As content blocks, `[...]`, which Typst parses at compile time. Errors
/// point at the generated file, and no `eval` is needed.
Content,
/// As quoted strings, which a template evaluates with
/// `eval(q.stem, mode: "markup")`. Required if the same payload is also
/// consumed as JSON, since JSON has no content type.
Str,
}
impl ContentMode {
/// Whether markup becomes a content block rather than a string.
pub fn is_content(self) -> bool {
self == ContentMode::Content
}
}
/// Whether a shared stimulus travels with each item that uses it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum StimulusMode {
/// Repeat the body with every item that references it. Wasteful on paper, but
/// a student should never have to turn a page to find the passage a question
/// is about.
Inline,
/// Emit only the id on the item, and the bodies once in the metadata under
/// `stimuli`. For a template that prints a testlet header above its group.
Shared,
/// Leave stimuli out.
Omit,
}
/// Which optional per-question fields are emitted.
///
/// Everything here defaults on except the two heavy ones. Turning a field off is
/// about payload noise, not secrecy — [`Reveal`] is what governs secrecy.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Fields {
/// The item's global id, `bank::item`. Useful printed in small grey type on a
/// review copy; never wanted on a student form.
#[serde(default = "yes")]
pub uid: bool,
/// The item's short title.
#[serde(default = "yes")]
pub title: bool,
/// Point value.
#[serde(default = "yes")]
pub points: bool,
/// Cognitive level, as both a number and a name.
#[serde(default = "yes")]
pub level: bool,
/// Learning objective ids.
#[serde(default = "yes")]
pub objectives: bool,
/// Topic tags.
#[serde(default = "yes")]
pub topics: bool,
/// Figures and data files attached to the item.
#[serde(default = "yes")]
pub assets: bool,
/// The letter the option carries in the bank, before shuffling. On a key this
/// is what lets you find the option in the YAML.
#[serde(default = "yes")]
pub source_letters: bool,
/// The authored predictions in the item's `design` block.
#[serde(default = "no")]
pub design: bool,
/// Pooled statistics from previous administrations.
#[serde(default = "no")]
pub calibration: bool,
}
impl Default for Fields {
fn default() -> Fields {
Fields {
uid: true,
title: true,
points: true,
level: true,
objectives: true,
topics: true,
assets: true,
source_letters: true,
design: false,
calibration: false,
}
}
}
/// The resolved configuration for rendering one variant.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RenderConfig {
/// A template path that overrides the usual lookup.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template: Option<PathBuf>,
/// The Typst function the `questions` slot calls, once per item, with a
/// single dictionary argument. The template defines it.
#[serde(default = "default_question_fn")]
pub question_fn: String,
/// The binding the `meta` slot declares.
#[serde(default = "default_meta_binding")]
pub meta_binding: String,
/// The binding the `data` slot declares.
#[serde(default = "default_data_binding")]
pub data_binding: String,
/// How much of the answer side to include.
#[serde(default = "default_reveal")]
pub reveal: Reveal,
/// How option labels are generated.
#[serde(default = "default_letters")]
pub letters: LetterStyle,
/// How markup is emitted.
#[serde(default = "default_content")]
pub content: ContentMode,
/// How shared stimuli are handled.
#[serde(default = "default_stimulus")]
pub stimulus: StimulusMode,
/// Which optional fields to include.
#[serde(default)]
pub fields: Fields,
/// Whether questions carry the number recorded in the assessment record
/// rather than their printed position.
///
/// Keep this on. The recorded number is the join key to every grading export
/// and response row; renumbering after a drop breaks that join silently, and
/// the symptom is item statistics attributed to the wrong question.
#[serde(default = "yes")]
pub number_from_record: bool,
/// Anything else you want the template to see, carried through untouched.
///
/// This is the escape hatch that keeps the crate out of your layout
/// decisions. Tier colours, a `show-solutions` flag, a font stack, a watermark
/// string: put it here and read it from `extra` in the template.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, serde_yaml_ng::Value>,
}
impl RenderConfig {
/// The built-in configuration for a variant.
///
/// # Arguments
///
/// * `variant` - which document.
///
/// # Returns
///
/// A configuration matching what the bundled template for that variant
/// expects.
pub fn for_variant(variant: Variant) -> RenderConfig {
let mut config = RenderConfig {
template: None,
question_fn: default_question_fn(),
meta_binding: default_meta_binding(),
data_binding: default_data_binding(),
reveal: Reveal::Nothing,
letters: LetterStyle::Upper,
content: ContentMode::Content,
stimulus: StimulusMode::Inline,
fields: Fields::default(),
number_from_record: true,
extra: BTreeMap::new(),
};
match variant {
Variant::Exam => {
// The paper must not carry the key in any form.
config.reveal = Reveal::Nothing;
config.fields.uid = false;
config.fields.source_letters = false;
config.fields.objectives = false;
}
Variant::Key => {
config.reveal = Reveal::Everything;
config.stimulus = StimulusMode::Omit;
}
Variant::AnswerSheet => {
config.reveal = Reveal::Nothing;
config.stimulus = StimulusMode::Omit;
config.fields = Fields {
uid: false,
title: false,
points: true,
level: false,
objectives: false,
topics: false,
assets: false,
source_letters: false,
design: false,
calibration: false,
};
}
}
config
}
/// Renders the configuration as YAML.
///
/// For `coursebank template config --resolved`, which is the answer to "which
/// layer won?" — a question that is otherwise answered by reading three files
/// and guessing.
///
/// # Returns
///
/// YAML text.
///
/// # Errors
///
/// Returns [`Error::Other`] if serialization
/// fails.
pub fn to_yaml(&self) -> Result<String> {
serde_yaml_ng::to_string(self).map_err(Error::other)
}
}
/// The file a course's Typst configuration lives in, under `templates/`.
pub const CONFIG_FILE: &str = "typst.yaml";
/// A course's Typst export configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
/// Schema version this file targets.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_version: Option<String>,
/// Overrides applied to every variant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub defaults: Option<Overrides>,
/// Overrides applied to one variant, on top of `defaults`.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub variants: BTreeMap<Variant, Overrides>,
}
impl ConfigFile {
/// Resolves the configuration for one variant.
///
/// # Arguments
///
/// * `variant` - which document.
///
/// # Returns
///
/// The built-in defaults with the file's `defaults` and then its
/// variant-specific block applied.
pub fn resolve(&self, variant: Variant) -> RenderConfig {
let mut config = RenderConfig::for_variant(variant);
if let Some(defaults) = &self.defaults {
defaults.apply(&mut config);
}
if let Some(specific) = self.variants.get(&variant) {
specific.apply(&mut config);
}
config
}
}
/// A partial [`RenderConfig`]: every field optional, so a config file can say one
/// thing without restating the rest.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Overrides {
/// See [`RenderConfig::template`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template: Option<PathBuf>,
/// See [`RenderConfig::question_fn`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub question_fn: Option<String>,
/// See [`RenderConfig::meta_binding`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta_binding: Option<String>,
/// See [`RenderConfig::data_binding`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_binding: Option<String>,
/// See [`RenderConfig::reveal`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reveal: Option<Reveal>,
/// See [`RenderConfig::letters`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub letters: Option<LetterStyle>,
/// See [`RenderConfig::content`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<ContentMode>,
/// See [`RenderConfig::stimulus`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stimulus: Option<StimulusMode>,
/// See [`RenderConfig::fields`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fields: Option<Fields>,
/// See [`RenderConfig::number_from_record`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub number_from_record: Option<bool>,
/// Merged key by key into [`RenderConfig::extra`] rather than replacing it, so
/// a variant can add one flag without repeating the shared block.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, serde_yaml_ng::Value>,
}
impl Overrides {
/// Applies these overrides in place.
///
/// # Arguments
///
/// * `config` - the configuration to modify.
pub fn apply(&self, config: &mut RenderConfig) {
if let Some(v) = &self.template {
config.template = Some(v.clone());
}
if let Some(v) = &self.question_fn {
config.question_fn = v.clone();
}
if let Some(v) = &self.meta_binding {
config.meta_binding = v.clone();
}
if let Some(v) = &self.data_binding {
config.data_binding = v.clone();
}
if let Some(v) = self.reveal {
config.reveal = v;
}
if let Some(v) = self.letters {
config.letters = v;
}
if let Some(v) = self.content {
config.content = v;
}
if let Some(v) = self.stimulus {
config.stimulus = v;
}
if let Some(v) = &self.fields {
config.fields = v.clone();
}
if let Some(v) = self.number_from_record {
config.number_from_record = v;
}
for (key, value) in &self.extra {
config.extra.insert(key.clone(), value.clone());
}
}
}
/// The commented starter config written by `coursebank template config`.
///
/// Written as text rather than serialized from [`ConfigFile`] because the comments
/// are the useful part, and a serializer drops them.
pub const CONFIG_TEMPLATE: &str = r#"# Typst export configuration.
#
# Layers, each overriding the last:
# 1. the built-in defaults for the variant
# 2. `defaults:` below
# 3. `variants:` below
#
# `coursebank template config --resolved` prints what a variant actually ends up
# with, which is the quickest way to check whether a key landed where you meant.
schema_version: "1.0"
defaults:
# The Typst function the `questions` slot calls once per item, with one
# dictionary argument. Your template defines it.
question_fn: render-question
# `content` emits stems as `[...]`, which Typst parses directly.
# `str` emits them as strings for a template that calls `eval`, and is what you
# want if the same payload is also read as JSON.
content: content
# upper | lower | numeric | roman | nothing
letters: upper
# Anything here is passed through untouched and shows up as `extra` in the
# payload. This is where layout decisions belong.
#
# Note the spelling shift: keys above are coursebank's and are snake_case like
# every other coursebank YAML file, but keys under `extra` are yours and reach
# Typst verbatim, so they use Typst's hyphens.
extra:
accent: '#017ab9'
font: 'Libertinus Serif'
variants:
exam:
# Leave this alone unless you are certain. `nothing` is what keeps the answer
# out of the student's copy; a template cannot leak a field it was never given.
reveal: nothing
extra:
show-solutions: false
key:
# nothing | key | everything
reveal: everything
extra:
show-solutions: true
answer-sheet:
reveal: nothing
"#;
/// Serde default: `true`.
fn yes() -> bool {
true
}
/// Serde default: `false`.
fn no() -> bool {
false
}
/// Serde default for [`RenderConfig::question_fn`].
fn default_question_fn() -> String {
"render-question".to_string()
}
/// Serde default for [`RenderConfig::meta_binding`].
fn default_meta_binding() -> String {
"cb-meta".to_string()
}
/// Serde default for [`RenderConfig::data_binding`].
fn default_data_binding() -> String {
"cb-data".to_string()
}
/// Serde default for [`RenderConfig::reveal`].
fn default_reveal() -> Reveal {
Reveal::Nothing
}
/// Serde default for [`RenderConfig::letters`].
fn default_letters() -> LetterStyle {
LetterStyle::Upper
}
/// Serde default for [`RenderConfig::content`].
fn default_content() -> ContentMode {
ContentMode::Content
}
/// Serde default for [`RenderConfig::stimulus`].
fn default_stimulus() -> StimulusMode {
StimulusMode::Inline
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn variant_tokens_round_trip() {
for variant in Variant::ALL {
assert_eq!(Variant::parse(variant.as_str()).unwrap(), variant);
}
// Underscores are tolerated because people type them.
assert_eq!(
Variant::parse("answer_sheet").unwrap(),
Variant::AnswerSheet
);
assert!(Variant::parse("bubbles").is_err());
}
#[test]
fn the_exam_variant_never_reveals_the_key_by_default() {
// This is the one default in this module that is a correctness property
// rather than a preference.
let config = RenderConfig::for_variant(Variant::Exam);
assert_eq!(config.reveal, Reveal::Nothing);
assert!(!config.reveal.shows_key());
}
#[test]
fn letters_are_generated_per_style() {
assert_eq!(LetterStyle::Upper.label(0), "A");
assert_eq!(LetterStyle::Upper.label(3), "D");
assert_eq!(LetterStyle::Lower.label(1), "b");
assert_eq!(LetterStyle::Numeric.label(4), "5");
assert_eq!(LetterStyle::Roman.label(3), "iv");
assert_eq!(LetterStyle::Nothing.label(2), "");
}
#[test]
fn letters_past_z_do_not_run_off_the_alphabet() {
// `b'A' + 26` is `[`. Anything that produced that would be a silent
// corruption of an option label rather than an error.
assert_eq!(LetterStyle::Upper.label(26), "AA");
assert_eq!(LetterStyle::Upper.label(25), "Z");
}
#[test]
fn config_layers_override_in_order() {
let file: ConfigFile = serde_yaml_ng::from_str(
"defaults:\n letters: lower\n extra:\n a: 1\nvariants:\n key:\n letters: \
numeric\n extra:\n b: 2\n",
)
.unwrap();
let exam = file.resolve(Variant::Exam);
assert_eq!(exam.letters, LetterStyle::Lower);
let key = file.resolve(Variant::Key);
assert_eq!(key.letters, LetterStyle::Numeric);
// `extra` merges rather than replacing, so `a` survives.
assert!(key.extra.contains_key("a"));
assert!(key.extra.contains_key("b"));
}
#[test]
fn an_empty_config_file_changes_nothing() {
let file = ConfigFile::default();
for variant in Variant::ALL {
assert_eq!(file.resolve(variant), RenderConfig::for_variant(variant));
}
}
#[test]
fn the_starter_config_parses_and_keeps_the_exam_closed() {
let file: ConfigFile = serde_yaml_ng::from_str(CONFIG_TEMPLATE).unwrap();
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
+749
View File
@@ -0,0 +1,749 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Loading a Typst file and splicing generated data into it.
//!
//! The previous version of this module built a document with `format!`. That made
//! every layout decision a code change, which is the wrong place for a decision
//! about where the points label sits. So the tool no longer writes documents; it
//! writes *data into* a document you own.
//!
//! ## Markers
//!
//! A template marks its injection points with Typst line comments, which means a
//! template is a valid `.typ` file that compiles on its own and can be styled
//! without this tool in the loop:
//!
//! ```typst
//! // coursebank:begin questions
//! #render-question((number: 1, stem: [Sample.], options: ()))
//! // coursebank:end questions
//! ```
//!
//! Everything between the `begin` and `end` lines is replaced; the marker lines
//! themselves survive. That has two consequences worth stating plainly:
//!
//! * The bundled templates ship with sample data inside their regions, so
//! `typst compile templates/exam.typ` works before any export has happened.
//! * An exported document is itself a valid template. Re-exporting into a file you
//! have since restyled replaces the questions and leaves your edits alone, which
//! is the difference between a generator you can use twice and one you copy out
//! of once.
//!
//! A bare `// coursebank:questions` with no region also works. It is rewritten
//! into a region on output, so the second export behaves like every subsequent one.
//!
//! ## Slots
//!
//! | Slot | Injected |
//! |:--|:--|
//! | `meta` | `#let cb-meta = (...)` — course, assessment, form, totals |
//! | `questions` | one `#render-question((...))` call per printed item |
//! | `data` | `#let cb-data = (...)` — metadata and questions together |
//!
//! `questions` unrolls the loop with the record's own numbering. `data` hands you
//! the array and gets out of the way. Templates are free to use either, both, or
//! neither; only slots the template actually contains are rendered, so nothing
//! costs anything until it is asked for.
//!
//! ## Lookup order
//!
//! 1. an explicit `--template` path, or `template:` in the render config
//! 2. `templates/<assessment-id>-<variant>.typ`, for a one-off layout
//! 3. `templates/<variant>.typ`, the course's own default
//! 4. the template compiled into this binary
//!
//! `coursebank template dump` writes step 4 into step 3 so that customizing means
//! editing a file rather than reading this documentation.
use std::path::{Path, PathBuf};
use crate::Layout;
use crate::error::{Error, Result};
use super::config::Variant;
/// The prefix every marker comment carries.
const MARKER: &str = "coursebank:";
/// The bundled exam paper template.
const EMBEDDED_EXAM: &str = include_str!("templates/exam.typ");
/// The bundled answer key template.
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");
/// An injection point a template can declare.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Slot {
/// Course, assessment, form, and totals, as a `#let` binding.
Meta,
/// One call per printed item.
Questions,
/// Metadata and questions together, as a `#let` binding.
Data,
}
impl Slot {
/// Every slot.
pub const ALL: [Slot; 3] = [Slot::Meta, Slot::Questions, Slot::Data];
/// The name used in a marker comment.
pub fn as_str(self) -> &'static str {
match self {
Slot::Meta => "meta",
Slot::Questions => "questions",
Slot::Data => "data",
}
}
/// The slot for a marker name.
fn parse(s: &str) -> Option<Slot> {
Slot::ALL.iter().copied().find(|slot| slot.as_str() == s)
}
/// A comma-separated list of every slot name, for error messages.
fn names() -> String {
Slot::ALL
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
}
}
/// Where a template came from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Origin {
/// Compiled into the binary.
Embedded,
/// Read from disk.
File(PathBuf),
}
impl std::fmt::Display for Origin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Origin::Embedded => f.write_str("built-in"),
Origin::File(path) => write!(f, "{}", path.display()),
}
}
}
/// A loaded template, with its markers already located.
#[derive(Debug, Clone)]
pub struct Template {
/// Which document this template produces.
pub variant: Variant,
/// Where it was loaded from.
pub origin: Origin,
/// The full source.
pub source: String,
/// The regions found, in the order they appear.
regions: Vec<Region>,
}
/// One located marker, as a half-open line range to replace.
#[derive(Debug, Clone)]
struct Region {
/// Which slot.
slot: Slot,
/// The marker line's leading whitespace, reapplied to every injected line so
/// data nested inside a Typst block stays readable.
indent: String,
/// First line index of the marker, which is the `begin` line for a region.
start: usize,
/// One past the `end` line index, or one past a bare point marker.
end: usize,
}
/// The source of the template compiled in for a variant.
///
/// # Arguments
///
/// * `variant` - which document.
///
/// # Returns
///
/// The bundled template source.
pub fn embedded(variant: Variant) -> &'static str {
match variant {
Variant::Exam => EMBEDDED_EXAM,
Variant::Key => EMBEDDED_KEY,
Variant::AnswerSheet => EMBEDDED_ANSWER_SHEET,
}
}
/// The directory holding a course's template overrides.
///
/// # Arguments
///
/// * `layout` - the course layout.
///
/// # Returns
///
/// The `templates/` directory, which need not exist.
pub fn dir(layout: &Layout) -> PathBuf {
layout.templates()
}
/// The paths that are consulted for a variant, in order.
///
/// Exposed so `coursebank template list` can show where a template would be found
/// and why, rather than leaving the lookup order to be inferred.
///
/// # Arguments
///
/// * `layout` - the course layout.
/// * `variant` - which document.
/// * `assessment_id` - the assessment being exported, if one is in hand.
///
/// # Returns
///
/// Candidate paths, most specific first.
pub fn candidates(layout: &Layout, variant: Variant, assessment_id: Option<&str>) -> Vec<PathBuf> {
let base = dir(layout);
let mut paths = Vec::new();
if let Some(id) = assessment_id {
paths.push(base.join(format!("{id}-{}", variant.template_file())));
}
paths.push(base.join(variant.template_file()));
paths
}
/// Loads the template for a variant.
///
/// # Arguments
///
/// * `layout` - the course layout.
/// * `variant` - which document.
/// * `assessment_id` - the assessment being exported, if one is in hand.
/// * `explicit` - a path that overrides the lookup entirely.
///
/// # Returns
///
/// The loaded template.
///
/// # Errors
///
/// Returns [`Error::Io`] when an explicitly requested template cannot be read, and
/// [`Error::Invalid`] when the template's markers are malformed. A missing file in
/// the lookup chain is not an error; it just falls through to the next candidate.
pub fn load(
layout: &Layout,
variant: Variant,
assessment_id: Option<&str>,
explicit: Option<&Path>,
) -> Result<Template> {
if let Some(path) = explicit {
let source = std::fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
return Template::parse(variant, Origin::File(path.to_path_buf()), source);
}
for path in candidates(layout, variant, assessment_id) {
if path.is_file() {
let source = std::fs::read_to_string(&path).map_err(|e| Error::io(&path, e))?;
return Template::parse(variant, Origin::File(path), source);
}
}
Template::parse(variant, Origin::Embedded, embedded(variant).to_string())
}
impl Template {
/// Parses a template, locating its markers.
///
/// # Arguments
///
/// * `variant` - which document.
/// * `origin` - where the source came from.
/// * `source` - the template source.
///
/// # Returns
///
/// The parsed template.
///
/// # Errors
///
/// Returns [`Error::Invalid`] listing every marker problem at once: an unknown
/// slot name, a `begin` with no `end`, an `end` with no `begin`, a nested
/// region, or the same slot claimed twice.
pub fn parse(variant: Variant, origin: Origin, source: String) -> Result<Template> {
let lines: Vec<&str> = source.lines().collect();
let mut regions: Vec<Region> = Vec::new();
let mut issues: Vec<String> = Vec::new();
let mut open: Option<(Slot, String, usize)> = None;
for (index, line) in lines.iter().enumerate() {
let Some(marker) = parse_marker(line) else {
continue;
};
let human = index + 1;
match marker.kind {
MarkerKind::Begin => {
if let Some((slot, _, at)) = &open {
issues.push(format!(
"line {human}: `begin {}` opens inside the region `{}` opened on line \
{}; regions cannot nest",
marker.name,
slot.as_str(),
at + 1
));
continue;
}
match Slot::parse(&marker.name) {
Some(slot) => open = Some((slot, marker.indent, index)),
None => issues.push(unknown_slot(human, &marker.name)),
}
}
MarkerKind::End => match open.take() {
Some((slot, indent, start)) => {
if slot.as_str() != marker.name {
issues.push(format!(
"line {human}: `end {}` closes the region `{}` opened on line \
{}",
marker.name,
slot.as_str(),
start + 1
));
}
regions.push(Region {
slot,
indent,
start,
end: index + 1,
});
}
None => issues.push(format!(
"line {human}: `end {}` has no matching `begin`",
marker.name
)),
},
MarkerKind::Point => {
if open.is_some() {
// A point marker inside a region would be overwritten by
// the region's own injection, so it is a mistake worth
// naming rather than silently dropping.
issues.push(format!(
"line {human}: the marker `{}` sits inside an open region and would be \
overwritten",
marker.name
));
continue;
}
match Slot::parse(&marker.name) {
Some(slot) => regions.push(Region {
slot,
indent: marker.indent,
start: index,
end: index + 1,
}),
None => issues.push(unknown_slot(human, &marker.name)),
}
}
}
}
if let Some((slot, _, start)) = open {
issues.push(format!(
"line {}: the region `{}` is never closed; add `// coursebank:end {}`",
start + 1,
slot.as_str(),
slot.as_str()
));
}
for slot in Slot::ALL {
let count = regions.iter().filter(|r| r.slot == slot).count();
if count > 1 {
issues.push(format!(
"the slot `{}` appears {count} times; each slot may be filled once",
slot.as_str()
));
}
}
if !issues.is_empty() {
issues.insert(0, format!("in the Typst template {origin}:"));
return Err(Error::Invalid(issues));
}
Ok(Template {
variant,
origin,
source,
regions,
})
}
/// Whether the template asks for a slot.
///
/// # Arguments
///
/// * `slot` - the slot to look for.
pub fn wants(&self, slot: Slot) -> bool {
self.regions.iter().any(|r| r.slot == slot)
}
/// The slots this template declares, in the order they appear.
pub fn slots(&self) -> Vec<Slot> {
self.regions.iter().map(|r| r.slot).collect()
}
/// Whether the template declares no markers at all.
pub fn is_inert(&self) -> bool {
self.regions.is_empty()
}
/// Renders the template with the given slot bodies.
///
/// # Arguments
///
/// * `bodies` - the Typst source to inject, by slot. A slot the template does
/// not declare is ignored; a slot the template declares but that has no body
/// is emitted as an empty region.
///
/// # Returns
///
/// The finished document. Every injected region is delimited by `begin`/`end`
/// markers, including regions that came from a bare point marker, so the
/// output can be used as the template for the next export.
pub fn render(&self, bodies: &[(Slot, String)]) -> String {
let lines: Vec<&str> = self.source.lines().collect();
let mut out = String::with_capacity(self.source.len() + 4096);
let mut cursor = 0usize;
// `parse` produced regions in source order and rejected overlaps, so a
// single forward pass is enough.
for region in &self.regions {
for line in &lines[cursor..region.start] {
out.push_str(line);
out.push('\n');
}
let body = bodies
.iter()
.find(|(slot, _)| *slot == region.slot)
.map(|(_, body)| body.as_str())
.unwrap_or("");
let name = region.slot.as_str();
let indent = region.indent.as_str();
out.push_str(indent);
out.push_str("// ");
out.push_str(MARKER);
out.push_str("begin ");
out.push_str(name);
out.push('\n');
for line in body.lines() {
if line.trim().is_empty() {
out.push('\n');
} else {
out.push_str(indent);
out.push_str(line);
out.push('\n');
}
}
out.push_str(indent);
out.push_str("// ");
out.push_str(MARKER);
out.push_str("end ");
out.push_str(name);
out.push('\n');
// Everything from the opening marker through the closing one has now
// been rewritten, so resume after it.
cursor = region.end;
}
for line in &lines[cursor..] {
out.push_str(line);
out.push('\n');
}
out
}
}
/// Writes the bundled templates into a directory.
///
/// # Arguments
///
/// * `dir` - the destination directory, created if absent.
/// * `variants` - which templates to write.
/// * `force` - whether to overwrite files that already exist.
///
/// # Returns
///
/// The paths written, and the paths skipped because they already existed.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory or a file cannot be written.
pub fn dump(dir: &Path, variants: &[Variant], force: bool) -> Result<(Vec<PathBuf>, Vec<PathBuf>)> {
std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
let mut written = Vec::new();
let mut skipped = Vec::new();
for variant in variants {
let path = dir.join(variant.template_file());
if path.exists() && !force {
// These are hand-edited files. Clobbering one is the sort of thing you
// discover after you have already lost the edit.
skipped.push(path);
continue;
}
crate::yaml::write_text(&path, embedded(*variant))?;
written.push(path);
}
Ok((written, skipped))
}
/// A marker comment, parsed.
struct Marker {
kind: MarkerKind,
name: String,
indent: String,
}
/// What a marker comment does.
enum MarkerKind {
/// Opens a replaceable region.
Begin,
/// Closes one.
End,
/// A standalone insertion point.
Point,
}
/// Parses one line as a marker comment, if it is one.
///
/// Recognized forms, with any leading whitespace and any run of `/` accepted:
///
/// ```text
/// // coursebank:questions
/// // coursebank:begin questions
/// // coursebank:end questions
/// ```
///
/// `begin`/`end` may also be spelled with a colon (`coursebank:begin:questions`),
/// because that is how people guess it.
fn parse_marker(line: &str) -> Option<Marker> {
let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
let rest = line[indent.len()..].trim_end();
let rest = rest
.strip_prefix("//")?
.trim_start_matches('/')
.trim_start();
let rest = rest.strip_prefix(MARKER)?.trim();
if rest.is_empty() {
return None;
}
let (kind, name) = if let Some(name) = strip_word(rest, "begin") {
(MarkerKind::Begin, name)
} else if let Some(name) = strip_word(rest, "end") {
(MarkerKind::End, name)
} else {
(MarkerKind::Point, rest)
};
Some(Marker {
kind,
name: name.trim().to_ascii_lowercase(),
indent,
})
}
/// Strips a leading `begin`/`end` keyword, separated by whitespace or a colon.
fn strip_word<'a>(s: &'a str, word: &str) -> Option<&'a str> {
let rest = s.strip_prefix(word)?;
match rest.chars().next() {
Some(c) if c.is_whitespace() || c == ':' => Some(rest[c.len_utf8()..].trim_start()),
// `begin` alone, with no slot named.
None => Some(""),
// `beginning`, which is a slot name that happens to start with `begin`.
Some(_) => None,
}
}
/// The message for a marker naming a slot that does not exist.
fn unknown_slot(line: usize, name: &str) -> String {
format!(
"line {line}: unknown slot `{name}`; expected one of {}",
Slot::names()
)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(source: &str) -> Result<Template> {
Template::parse(Variant::Exam, Origin::Embedded, source.to_string())
}
#[test]
fn a_point_marker_is_replaced_and_becomes_a_region() {
// The second export has to behave like every one after it, so a point
// marker is rewritten into a region on the way out.
let template = parse("before\n// coursebank:questions\nafter\n").unwrap();
let out = template.render(&[(Slot::Questions, "#q(1)".to_string())]);
assert!(out.contains("before\n"));
assert!(out.contains("// coursebank:begin questions\n#q(1)\n"));
assert!(out.contains("// coursebank:end questions\n"));
assert!(out.contains("after\n"));
assert!(!out.contains("#q(1)\n#q(1)"));
}
#[test]
fn a_region_replaces_its_body_and_keeps_its_markers() {
let template = parse(
"head\n// coursebank:begin questions\nstale sample data\nmore stale\n// \
coursebank:end questions\ntail\n",
)
.unwrap();
let out = template.render(&[(Slot::Questions, "#q(1)".to_string())]);
assert!(!out.contains("stale"), "old body survived: {out}");
assert!(out.contains("#q(1)"));
assert!(out.contains("head\n"));
assert!(out.contains("tail\n"));
}
#[test]
fn rendering_is_idempotent() {
// Exporting into a file that was itself exported must replace the
// questions and leave the surrounding edits alone. This is the property
// that makes "restyle the output, then re-export" a workable habit.
let template = parse("// coursebank:questions\n").unwrap();
let first = template.render(&[(Slot::Questions, "#q(1)".to_string())]);
let again = parse(&first).unwrap();
let second = again.render(&[(Slot::Questions, "#q(2)".to_string())]);
assert!(second.contains("#q(2)"));
assert!(!second.contains("#q(1)"));
assert_eq!(second.matches("coursebank:begin questions").count(), 1);
assert_eq!(second.matches("coursebank:end questions").count(), 1);
}
#[test]
fn indentation_is_reapplied_to_injected_lines() {
let template = parse("#block[\n // coursebank:questions\n]\n").unwrap();
let out = template.render(&[(Slot::Questions, "#q(\n 1,\n)".to_string())]);
assert!(out.contains(" // coursebank:begin questions"), "{out}");
assert!(out.contains(" #q("), "{out}");
assert!(out.contains(" 1,"), "{out}");
}
#[test]
fn only_declared_slots_are_reported() {
let template = parse("// coursebank:meta\n// coursebank:questions\n").unwrap();
assert!(template.wants(Slot::Meta));
assert!(template.wants(Slot::Questions));
assert!(!template.wants(Slot::Data));
assert_eq!(template.slots(), vec![Slot::Meta, Slot::Questions]);
}
#[test]
fn a_template_with_no_markers_is_reported_as_inert() {
// Not an error: someone may want a fully hand-written paper. But the CLI
// warns, because silently writing a document with no questions in it is
// not a good afternoon.
let template = parse("#set page(paper: \"us-letter\")\n").unwrap();
assert!(template.is_inert());
}
#[test]
fn an_unknown_slot_names_the_valid_ones() {
let err = parse("// coursebank:qustions\n").unwrap_err();
let message = err.to_string();
assert!(message.contains("unknown slot `qustions`"), "{message}");
assert!(message.contains("questions"), "{message}");
}
#[test]
fn unbalanced_regions_are_reported_with_line_numbers() {
let err = parse("a\n// coursebank:begin questions\nb\n").unwrap_err();
assert!(err.to_string().contains("never closed"));
let err = parse("// coursebank:end questions\n").unwrap_err();
assert!(err.to_string().contains("no matching `begin`"));
}
#[test]
fn a_duplicated_slot_is_an_error() {
let err = parse("// coursebank:questions\n// coursebank:questions\n").unwrap_err();
assert!(err.to_string().contains("appears 2 times"));
}
#[test]
fn mismatched_region_names_are_reported() {
let err = parse("// coursebank:begin questions\n// coursebank:end meta\n").unwrap_err();
assert!(err.to_string().contains("closes the region"));
}
#[test]
fn every_problem_is_reported_in_one_pass() {
let err = parse("// coursebank:nope\n// coursebank:end data\n").unwrap_err();
let message = err.to_string();
assert!(message.contains("unknown slot"), "{message}");
assert!(message.contains("no matching `begin`"), "{message}");
}
#[test]
fn marker_spelling_is_forgiving() {
assert!(parse("// coursebank:begin:questions\n// coursebank:end:questions\n").is_ok());
assert!(parse(" // coursebank: questions\n").is_ok());
assert!(parse("/// coursebank:questions\n").is_ok());
assert!(parse("// coursebank:QUESTIONS\n").is_ok());
}
#[test]
fn ordinary_comments_are_left_alone() {
assert!(
parse("// nothing to see\n// coursebank\n")
.unwrap()
.is_inert()
);
// A word that merely starts with `begin` is a slot name, not a keyword.
let err = parse("// coursebank:beginning\n").unwrap_err();
assert!(err.to_string().contains("unknown slot `beginning`"));
}
#[test]
fn a_missing_body_leaves_an_empty_region() {
let template = parse("// coursebank:questions\n").unwrap();
let out = template.render(&[]);
assert!(out.contains("// coursebank:begin questions"));
assert!(out.contains("// coursebank:end questions"));
}
#[test]
fn every_bundled_template_parses_and_declares_slots() {
for variant in Variant::ALL {
let template =
Template::parse(variant, Origin::Embedded, embedded(variant).to_string())
.unwrap_or_else(|e| panic!("bundled {} template: {e}", variant.as_str()));
assert!(
!template.is_inert(),
"the bundled {} template declares no slots",
variant.as_str()
);
}
}
#[test]
fn the_lookup_order_is_most_specific_first() {
let layout = Layout::new("/course");
let paths = candidates(&layout, Variant::Key, Some("exam-2"));
assert_eq!(paths.len(), 2);
assert!(paths[0].ends_with("templates/exam-2-key.typ"));
assert!(paths[1].ends_with("templates/key.typ"));
}
}
@@ -0,0 +1,70 @@
// coursebank — answer sheet template
//
// Bubbles are generated from each item's own option count, so an item with three
// options gets three bubbles rather than the five the rest of the form has. That is
// the whole reason this document is generated at all: a hand-drawn sheet drifts out
// of step with the paper, and the drift is invisible until it is scanned.
//
// // coursebank:begin data
// // coursebank:end data
//
// If you scan these, set `bubble-radius` and the column count in
// 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.
// coursebank:begin data
#let cb-data = (
course: (code: "COURSE 101", title: "Sample Course", term: "2026s"),
assessment: (id: "sample", title: "Sample assessment", date: "2026-01-01"),
form: (id: "A", count: 1),
totals: (questions: 1, scored: 1, bonus: 0, points: 1.5, bonus-points: 0.0),
extra: (:),
questions: (
(
number: 1,
bonus: false,
stem: [Replaced on the next export.],
options: (
(letter: "A", position: 1, text: [First.]),
(letter: "B", position: 2, text: [Second.]),
),
),
),
)
// coursebank:end data
#let extra = cb-data.at("extra", default: (:))
#let bubble-radius = eval(extra.at("bubble-radius", default: "0.42em"))
#let columns-count = extra.at("columns", default: 2)
#set page(paper: extra.at("paper", default: "us-letter"), margin: 1.5cm)
#set text(size: 10pt)
#let bubble(letter) = circle(radius: bubble-radius, stroke: 0.5pt)[
#align(center + horizon)[#text(size: 0.7em)[#letter]]
]
= #cb-data.assessment.title — answer sheet (form #cb-data.form.id)
#grid(
columns: (auto, 1fr, auto, 1fr),
gutter: 0.6em,
[*Name*], box(width: 100%, repeat[.]), [*Student ID*], box(width: 100%, repeat[.]),
)
#v(1em)
#columns(columns-count)[
#for q in cb-data.questions {
block(below: 0.45em)[
#box(width: 2em)[#(str(q.number) + ".")]
#for opt in q.at("options", default: ()) {
bubble(opt.letter)
h(0.25em)
}
#if q.at("bonus", default: false) {
text(size: 0.7em, fill: luma(120))[ bonus]
}
]
}
]
+222
View File
@@ -0,0 +1,222 @@
// coursebank — exam paper template
//
// This file is a template, not generated output. `coursebank export typst`
// 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 write this file into templates/
// typst watch templates/exam.typ restyle it against the sample data
//
// The regions ship with sample values so that last command works before any
// export has happened. Two markers are in play:
//
// // coursebank:begin meta a dictionary of course and form metadata
// // coursebank:end meta
// // coursebank:begin questions one #render-question(...) call per item
// // coursebank:end questions
//
// An exported document keeps its markers, so exporting into a file you have since
// restyled replaces the questions and leaves the styling alone.
//
// Note what is *not* in the payload for this variant: there is no `correct` field
// on an option, because the render config for the paper withholds it. That is
// deliberate. Do not switch `reveal` to `key` here in order to build a solutions
// copy — export the `key` variant instead, or the day you forget an `if` is the
// day the class gets the answers.
// ─────────────────────────────────────────────────────────────────────────────
// Metadata
// ─────────────────────────────────────────────────────────────────────────────
// coursebank:begin meta
#let cb-meta = (
course: (code: "COURSE 101", title: "Sample Course", term: "2026s"),
assessment: (
id: "sample",
title: "Sample assessment",
date: "2026-01-01",
minutes-allowed: 50.0,
instructions: [Choose the single best answer unless a question says otherwise.],
),
form: (id: "A", count: 1),
totals: (questions: 1, scored: 1, bonus: 0, points: 1.5, bonus-points: 0.0),
extra: (:),
)
// coursebank:end meta
// ─────────────────────────────────────────────────────────────────────────────
// 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-meta.at("extra", default: (:))
#let accent = rgb(extra.at("accent", default: "#1f4e79"))
#let body-font = extra.at("font", default: "Libertinus Serif")
#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 form-note = if cb-meta.form.at("count", default: 1) > 1 {
" · Form " + cb-meta.form.id
} else {
""
}
#set page(
paper: paper,
margin: 2cm,
header: text(size: 0.85em)[
#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)
],
)
#set text(font: body-font, size: body-size, lang: "en")
#set par(justify: false, leading: 0.65em)
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
// Markup arrives as content when the render config says `content: content`, and as
// a string when it says `content: str`. Accepting both means switching that
// setting does not require editing the template.
#let markup(v) = if type(v) == str { eval(v, mode: "markup") } else { v }
// 1 point, 2 points, 1.5 points.
#let fmt-points(p) = {
let n = if p == calc.trunc(p) { str(calc.trunc(p)) } else { str(p) }
n + if p == 1 { " point" } else { " points" }
}
#let points-tag(q) = {
let p = q.at("points", default: none)
let bonus = q.at("bonus", default: false)
let label = if bonus and show-points and p != none {
"bonus, " + fmt-points(p)
} else if bonus {
"bonus"
} else if show-points and p != none {
fmt-points(p)
} else {
none
}
if label != none {
text(fill: luma(100), size: 0.9em)[(#label)]
}
}
#let stimulus-block(s) = {
block(stroke: 0.5pt + luma(180), inset: 8pt, radius: 3pt, width: 100%)[
#markup(s.body)
]
let caption = s.at("caption", default: none)
if caption != none {
block(above: 0.3em)[#text(size: 0.85em, style: "italic")[#markup(caption)]]
}
}
// ─────────────────────────────────────────────────────────────────────────────
// The renderer
// ─────────────────────────────────────────────────────────────────────────────
//
// One question, one function. Rename it if you like and set `question-fn` in
// templates/typst.yaml to match. It takes a single dictionary so that turning a
// field on or off in the config never changes this signature.
#let render-question(q) = {
// A stimulus shared by several items is printed with each of them. That repeats
// material, but a student should never have to turn a page to find the passage a
// question refers to.
let s = q.at("stimulus", default: none)
if s != none { stimulus-block(s) }
// 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)],
)
v(0.15em)
}
]
if page-per-item { pagebreak(weak: true) }
}
// ─────────────────────────────────────────────────────────────────────────────
// The page
// ─────────────────────────────────────────────────────────────────────────────
#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(size: 0.9em)[
#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) }
}
]
]
#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[.]),
)
]
}
#{
let instructions = cb-meta.assessment.at("instructions", default: none)
if instructions != none {
block(fill: luma(245), inset: 8pt, radius: 3pt, width: 100%)[
#markup(instructions)
]
}
}
// coursebank:begin questions
#render-question((
number: 1,
points: 1.5,
bonus: false,
multi-select: false,
stem: [This sample question is replaced on the next export.],
options: (
(letter: "A", position: 1, text: [The first option.]),
(letter: "B", position: 2, text: [The second option.]),
),
))
// coursebank:end questions
+156
View File
@@ -0,0 +1,156 @@
// coursebank — answer key template
//
// This one uses the `data` slot rather than `questions`: it gets the whole payload
// as a single dictionary and loops over it here, which suits a table better than an
// unrolled sequence of calls. Both slots are always available; use whichever fits
// what you are building.
//
// // coursebank:begin data
// // coursebank:end data
//
// The key's render config sets `reveal: everything`, so options here carry
// `correct`, `credit`, and the authored rationale. That is the opposite of the
// paper's config, and it is why these are two templates rather than one with a
// flag.
// coursebank:begin data
#let cb-data = (
course: (code: "COURSE 101", title: "Sample Course", term: "2026s"),
assessment: (id: "sample", title: "Sample assessment", date: "2026-01-01"),
form: (id: "A", count: 1),
totals: (questions: 1, scored: 1, bonus: 0, points: 1.5, bonus-points: 0.0),
dropped: (),
extra: (:),
questions: (
(
number: 1,
points: 1.5,
bonus: false,
level: 2,
level-name: "Understand",
stem: [This sample question is replaced on the next export.],
key: ("B",),
options: (
(letter: "A", position: 1, text: [Wrong.], correct: false, credit: 0.0),
(letter: "B", position: 2, text: [Right.], correct: true, credit: 1.0),
),
),
),
)
// coursebank:end data
#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)
#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) }
#let objectives-of(q) = q.at("learning-objectives", default: ()).join(", ")
= #cb-data.assessment.title — answer key (form #cb-data.form.id)
#text(size: 0.9em, style: "italic")[
Letters below are the letters *as printed on this form*. Do not use this key on
another form. Form seed #cb-data.form.at("seed", default: "—").
]
#v(0.5em)
#table(
columns: (auto, auto, auto, auto, 1fr),
align: (right, center, center, right, left),
table.header([*\#*], [*Key*], [*Level*], [*Pts*], [*Objectives*]),
..cb-data
.questions
.map(q => (
[#q.number],
[*#q.at("key", default: ()).join("")*],
[#str(q.at("level", default: "—"))],
[#fmt-points(q.at("points", default: 0))],
[#objectives-of(q)],
))
.flatten(),
)
// ── Partial credit ──
// Recorded against the letters the students actually saw, so a grader can apply it
// without remapping anything.
#{
let rows = cb-data.questions.filter(q => q.at("credit-overrides", default: (:)).len() > 0)
if rows.len() > 0 {
heading(level: 2)[Partial credit]
list(
..rows.map(q => {
let parts = q
.at("credit-overrides", default: (:))
.pairs()
.map(pair => pair.at(0) + " = " + str(int(calc.round(pair.at(1) * 100))) + "%")
[Question #q.number: #parts.join(", ")]
}),
)
}
}
// ── Dropped ──
#{
let dropped = cb-data.at("dropped", default: ())
if dropped.len() > 0 {
heading(level: 2)[Dropped]
[
Question(s) #dropped.map(str).join(", ") were dropped after administration
and are not printed.
]
}
}
// ── Rationale ──
// Everything below exists because a key you can grade from is not the same
// document as a key you can defend a challenge with.
#pagebreak(weak: true)
#heading(level: 2)[Rationale]
#for q in cb-data.questions {
block(breakable: false, above: 1.2em)[
#text(weight: "bold")[Question #q.number]
#{
let uid = q.at("uid", default: none)
if uid != none { text(size: 0.85em, fill: luma(120))[ · #uid] }
}
#markup(q.stem)
#for opt in q.at("options", default: ()) {
let correct = opt.at("correct", default: false)
let credit = opt.at("credit", default: 0.0)
// Drawn rather than typed: a check-mark glyph is not in every font, and a
// missing glyph on an answer key is a box where the answer should be.
let marker = if correct {
box(width: 0.55em, height: 0.55em, radius: 1pt, fill: accent)
} else if credit > 0 {
box(width: 0.55em, height: 0.55em, radius: 1pt, stroke: 0.6pt + accent)
} else {
box(width: 0.55em, height: 0.55em)
}
grid(
columns: (1.2em, 1.4em, 1fr),
gutter: 0.3em,
[#marker],
[#(opt.letter + ".")],
[
#markup(opt.text)
#{
let why = opt.at("explanation", default: opt.at("misconception", default: none))
if why != none {
linebreak()
text(size: 0.85em, fill: luma(90), style: "italic")[#markup(why)]
}
}
],
)
}
]
}
+353
View File
@@ -0,0 +1,353 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! A small writer for Typst literals.
//!
//! Everything this crate injects into a template is data, not layout, and data
//! has to arrive as syntax the Typst parser accepts. That is a narrow enough job
//! to do by hand: eight value kinds, one recursive printer, and no dependency on
//! a Typst implementation.
//!
//! Two details in here exist only because Typst's parenthesis syntax is
//! overloaded, and both are the kind of thing that produces a confusing compile
//! error rather than a clear one if you get them wrong:
//!
//! * An empty dictionary is `(:)`, not `()`, because `()` is the empty array.
//! * A one-element array needs a trailing comma — `(1,)` — because `(1)` is just
//! a parenthesized expression. Trailing commas are harmless everywhere else, so
//! this printer always emits them.
//!
//! [`Value::Content`] is the reason this is not simply a JSON writer. Stems and
//! option text are authored in a Typst subset, so they can be emitted as a
//! content block that Typst parses directly. The alternative is a quoted string
//! the template has to `eval`, which is what [`Value::Str`] gives you and what
//! JSON interoperability requires. Both are supported because both are useful;
//! see [`crate::typst::config::ContentMode`].
use std::fmt::Write as _;
/// One value in an emitted Typst literal.
#[derive(Debug, Clone, PartialEq)]
pub enum Value {
/// Typst's `none`.
None,
/// A boolean.
Bool(bool),
/// An integer.
Int(i64),
/// A float. Non-finite values are written as `none`, since Typst has no
/// literal for them and a NaN in a point total is a bug worth seeing.
Float(f64),
/// A quoted string.
Str(String),
/// Markup, emitted as a content block: `[...]`.
Content(String),
/// Verbatim Typst code, emitted with no quoting or escaping at all.
Raw(String),
/// An array.
Array(Vec<Value>),
/// A dictionary. Insertion order is preserved, because the output is meant to
/// be read by a human comparing two exports.
Dict(Vec<(String, Value)>),
}
impl Value {
/// A string value.
pub fn str(s: impl Into<String>) -> Value {
Value::Str(s.into())
}
/// A content-block value.
pub fn content(s: impl Into<String>) -> Value {
Value::Content(s.into())
}
/// An empty dictionary, ready for [`Value::insert`].
pub fn dict() -> Value {
Value::Dict(Vec::new())
}
/// Adds a key to a dictionary, ignoring the call on any other kind.
///
/// # Arguments
///
/// * `key` - the dictionary key.
/// * `value` - the value to store.
pub fn insert(&mut self, key: impl Into<String>, value: Value) {
if let Value::Dict(entries) = self {
entries.push((key.into(), value));
}
}
/// Adds a key only when the value is `Some`, so an absent field is absent
/// from the output rather than present and `none`.
///
/// This distinction carries real weight for the answer key: an exam paper
/// whose payload omits `correct` cannot leak the key through a template that
/// forgot to check a flag, whereas one that emits `correct: none` invites a
/// template to treat the field as present.
///
/// # Arguments
///
/// * `key` - the dictionary key.
/// * `value` - the value, if there is one.
pub fn insert_some(&mut self, key: impl Into<String>, value: Option<Value>) {
if let Some(value) = value {
self.insert(key, value);
}
}
/// Whether this is a dictionary with no entries.
pub fn is_empty_dict(&self) -> bool {
matches!(self, Value::Dict(entries) if entries.is_empty())
}
/// Renders the value as a Typst literal.
///
/// # Arguments
///
/// * `indent` - how many levels of two-space indentation the value starts at.
/// Nested values indent relative to this.
///
/// # Returns
///
/// Typst source. Multi-line for non-empty arrays and dictionaries, single-line
/// for everything else.
pub fn to_typst(&self, indent: usize) -> String {
let mut out = String::new();
write_value(&mut out, self, indent);
out
}
}
/// Writes one value at the given indentation level.
fn write_value(out: &mut String, value: &Value, indent: usize) {
match value {
Value::None => out.push_str("none"),
Value::Bool(true) => out.push_str("true"),
Value::Bool(false) => out.push_str("false"),
Value::Int(n) => {
let _ = write!(out, "{n}");
}
Value::Float(x) => {
if x.is_finite() {
let _ = write!(out, "{x}");
} else {
out.push_str("none");
}
}
Value::Str(s) => write_string(out, s),
Value::Content(s) => {
out.push('[');
out.push_str(s);
out.push(']');
}
Value::Raw(s) => out.push_str(s),
Value::Array(items) => {
if items.is_empty() {
out.push_str("()");
return;
}
out.push_str("(\n");
for item in items {
pad(out, indent + 1);
write_value(out, item, indent + 1);
out.push_str(",\n");
}
pad(out, indent);
out.push(')');
}
Value::Dict(entries) => {
if entries.is_empty() {
// Not `()`, which is the empty array.
out.push_str("(:)");
return;
}
out.push_str("(\n");
for (key, item) in entries {
pad(out, indent + 1);
write_key(out, key);
out.push_str(": ");
write_value(out, item, indent + 1);
out.push_str(",\n");
}
pad(out, indent);
out.push(')');
}
}
}
/// Writes `n` levels of two-space indentation.
fn pad(out: &mut String, n: usize) {
for _ in 0..n {
out.push_str(" ");
}
}
/// Writes a dictionary key, quoting it when it is not a bare identifier.
fn write_key(out: &mut String, key: &str) {
if is_identifier(key) {
out.push_str(key);
} else {
write_string(out, key);
}
}
/// Whether a string can be used as a bare Typst identifier.
///
/// Typst identifiers allow interior hyphens, which is why `render-question` is a
/// legal function name and why this is not simply a Rust identifier check.
fn is_identifier(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_alphabetic() || c == '_' => {}
_ => return false,
}
chars.all(|c| c.is_alphanumeric() || c == '_' || c == '-')
}
/// Writes a quoted, escaped Typst string.
fn write_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.push('"');
}
/// Converts a parsed YAML value into a Typst value.
///
/// This is how arbitrary user configuration reaches a template: whatever is
/// under `extra` in the render config is carried through unexamined, so a
/// template can be given values this crate has never heard of.
///
/// # Arguments
///
/// * `value` - the YAML value.
/// * `strings_as_content` - when true, strings become content blocks rather than
/// quoted strings.
///
/// # Returns
///
/// The equivalent Typst value. YAML constructs with no Typst equivalent, such as
/// a tagged node, become `none`.
pub fn from_yaml(value: &serde_yaml_ng::Value, strings_as_content: bool) -> Value {
match value {
serde_yaml_ng::Value::Null => Value::None,
serde_yaml_ng::Value::Bool(b) => Value::Bool(*b),
serde_yaml_ng::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Value::Int(i)
} else if let Some(f) = n.as_f64() {
Value::Float(f)
} else {
Value::None
}
}
serde_yaml_ng::Value::String(s) => {
if strings_as_content {
Value::Content(s.clone())
} else {
Value::Str(s.clone())
}
}
serde_yaml_ng::Value::Sequence(items) => Value::Array(
items
.iter()
.map(|i| from_yaml(i, strings_as_content))
.collect(),
),
serde_yaml_ng::Value::Mapping(map) => {
let mut entries = Vec::new();
for (key, item) in map {
// A non-scalar key has no Typst spelling; skip it rather than
// emit something that will not parse.
let key = match key {
serde_yaml_ng::Value::String(s) => s.clone(),
serde_yaml_ng::Value::Number(n) => n.to_string(),
serde_yaml_ng::Value::Bool(b) => b.to_string(),
_ => continue,
};
entries.push((key, from_yaml(item, strings_as_content)));
}
Value::Dict(entries)
}
// A tagged node, and anything a future YAML version adds.
_ => Value::None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_containers_use_the_right_syntax() {
// `()` is the empty array and `(:)` is the empty dictionary. Swapping
// them produces a type error deep inside the template.
assert_eq!(Value::Array(Vec::new()).to_typst(0), "()");
assert_eq!(Value::dict().to_typst(0), "(:)");
}
#[test]
fn single_element_arrays_keep_the_trailing_comma() {
let v = Value::Array(vec![Value::Int(1)]);
let out = v.to_typst(0);
assert!(out.contains("1,"), "got {out}");
}
#[test]
fn strings_are_escaped() {
assert_eq!(Value::str("a\"b\\c").to_typst(0), "\"a\\\"b\\\\c\"");
assert_eq!(Value::str("two\nlines").to_typst(0), "\"two\\nlines\"");
}
#[test]
fn content_is_not_escaped() {
// Content blocks carry authored markup through verbatim; escaping them
// would turn `*bold*` into literal asterisks.
assert_eq!(Value::content("*bold*").to_typst(0), "[*bold*]");
}
#[test]
fn keys_are_quoted_only_when_they_have_to_be() {
let mut d = Value::dict();
d.insert("error-type", Value::Int(1));
d.insert("2nd", Value::Int(2));
let out = d.to_typst(0);
assert!(out.contains("error-type: 1"), "got {out}");
assert!(out.contains("\"2nd\": 2"), "got {out}");
}
#[test]
fn absent_fields_are_omitted_entirely() {
let mut d = Value::dict();
d.insert_some("correct", None);
d.insert_some("number", Some(Value::Int(3)));
assert!(!d.to_typst(0).contains("correct"));
}
#[test]
fn non_finite_floats_do_not_produce_invalid_syntax() {
assert_eq!(Value::Float(f64::NAN).to_typst(0), "none");
assert_eq!(Value::Float(1.5).to_typst(0), "1.5");
}
#[test]
fn yaml_passes_through() {
let yaml: serde_yaml_ng::Value =
serde_yaml_ng::from_str("a: 1\nb: [x, y]\nc: true\n").unwrap();
let out = from_yaml(&yaml, false).to_typst(0);
assert!(out.contains("a: 1"), "got {out}");
assert!(out.contains("\"x\""), "got {out}");
assert!(out.contains("c: true"), "got {out}");
}
}
+60
View File
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Long-form documentation: setup, authoring, and worked tutorials.
//!
//! Everything in this module is prose. The modules below hold no code, no types,
//! and no runtime cost; each one exists so that a markdown file under `docs/guide/`
//! gets a page in these docs, a slot in the sidebar, and a stable URL that
//! [intra-doc links](https://doc.rust-lang.org/rustdoc/write-documentation/linking-to-items-by-name.html)
//! elsewhere in the crate can point at.
//!
//! ## Read in this order
//!
//! The sidebar sorts alphabetically, which is not reading order. This is:
//!
//! 1. [`setup`] builds a course directory from nothing and explains what each file
//! is for.
//! 2. [`authoring`] writes items, with the design fields that make an item worth
//! reusing.
//! 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
//! know the shape of the tool.
//!
//! ## Why the tutorials are in here rather than a wiki
//!
//! Rust examples in these pages are doctests. `cargo test --doc` compiles every
//! one of them against the crate as it currently is, so renaming
//! [`Catalog::require`](crate::catalog::Catalog::require) breaks the documentation
//! build rather than leaving a page that lies. Most examples carry `no_run`,
//! because they want a course directory on disk that a test runner does not have.
//! `no_run` still type-checks, which is where the value is.
//!
//! Shell transcripts get a `console` fence and YAML gets a `yaml` fence, so
//! rustdoc leaves them alone. A fence with no language is Rust as far as rustdoc is
//! concerned, and a `course.yaml` snippet in a bare fence fails the doc build with
//! a parse error pointing at the markdown. `pixi run check-docs` catches that
//! before the compiler has to.
/// Building a course directory, and what each file in it is for.
#[doc = include_str!("../docs/guide/setup.md")]
pub mod setup {}
/// Writing items that are worth keeping.
#[doc = include_str!("../docs/guide/authoring.md")]
pub mod authoring {}
/// One exam from blueprint to student report.
#[doc = include_str!("../docs/guide/first_exam.md")]
pub mod first_exam {}
/// Printed exams: templates, markers, and render configuration.
#[doc = include_str!("../docs/TYPST.md")]
pub mod typst_export {}
/// Short answers to specific questions.
#[doc = include_str!("../docs/guide/recipes.md")]
pub mod recipes {}
+384
View File
@@ -0,0 +1,384 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Plumbing shared by more than one command handler.
//!
//! Loading and resolving ([`load`], [`load_record`], [`pick_form`]), building an
//! ingest context ([`context`]) and reading responses ([`responses_for`]), the
//! security-sensitive [`read_salt`], the `KEY=VALUE` flag parsers
//! ([`parse_level_map`], [`parse_string_map`]), and the shared text formatting
//! ([`print_record`], [`markdown_export`], [`truncate`]).
//!
//! Anything used by only one handler stays with that handler; the items here
//! earned a home in the shared module by having more than one caller.
use std::collections::BTreeMap;
use std::path::Path;
use coursebank::assessment::{AssessmentFile, Form};
use coursebank::catalog::Catalog;
use coursebank::course::COURSE_FILE;
use coursebank::date::Date;
use coursebank::error::{Error, Result};
use coursebank::gradescope;
use coursebank::layout::Layout;
use coursebank::responses::ResponseSet;
use coursebank::store::Store;
use coursebank::taxonomy::Level;
use crate::cli::{Cli, IngestCommon};
/// Loads the catalog, with a friendlier message when the directory is not a course.
pub(crate) fn load(cli: &Cli) -> Result<Catalog> {
let course_file = Layout::new(&cli.course).course_file();
if !course_file.exists() {
return Err(Error::usage(format!(
"{} has no {COURSE_FILE}. Run `coursebank init --code ... --title ... --term ...` \
here, or point at a course with --course",
cli.course.display()
)));
}
Catalog::load(&cli.course)
}
/// Loads one assessment record by id.
pub(crate) fn load_record(catalog: &Catalog, id: &str) -> Result<AssessmentFile> {
let path = catalog.layout.assessments().join(format!("{id}.yaml"));
if path.exists() {
return AssessmentFile::load(&path);
}
// Fall back to scanning, in case the file name and the id differ.
let all = AssessmentFile::load_all(&catalog.layout.assessments())?;
all.into_iter()
.find(|r| r.assessment.id == id)
.ok_or_else(|| Error::Unresolved {
kind: "assessment",
id: id.to_string(),
context: Some(catalog.layout.assessments().display().to_string()),
})
}
/// Picks a form by id, defaulting sensibly when the record declares none.
pub(crate) fn pick_form(record: &AssessmentFile, id: &str) -> Result<Form> {
if record.forms.is_empty() {
return Ok(Form {
id: id.to_string(),
seed: 0,
shuffle_items: false,
shuffle_options: false,
});
}
record
.forms
.iter()
.find(|f| f.id.eq_ignore_ascii_case(id))
.cloned()
.ok_or_else(|| {
Error::usage(format!(
"no form `{id}` on this assessment; it declares {}",
record
.forms
.iter()
.map(|f| f.id.as_str())
.collect::<Vec<_>>()
.join(", ")
))
})
}
/// Builds the ingest context from a record and the command line.
pub(crate) fn context(
catalog: &Catalog,
record: &AssessmentFile,
common: &IngestCommon,
) -> Result<gradescope::Context> {
let date = match &common.date {
Some(s) => Some(s.parse::<Date>()?),
None => record.assessment.date,
};
Ok(gradescope::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,
form: common.form.clone(),
})
}
/// Reads the responses to analyze, either one administration or all of them.
pub(crate) fn responses_for(
store: &Store,
catalog: &Catalog,
record: &AssessmentFile,
pooled: bool,
) -> Result<ResponseSet> {
let set = if pooled {
store.read_assessment(&record.assessment.id)?
} else {
let admin = coursebank::responses::administration_id(
&catalog.course.course.code,
record
.assessment
.term
.as_deref()
.unwrap_or(&catalog.course.course.term),
&record.assessment.id,
);
store.read(&admin)?
};
if set.rows.is_empty() {
return Err(Error::Other(format!(
"no stored responses for `{}`. Run `coursebank ingest` first",
record.assessment.id
)));
}
Ok(set)
}
/// Reads the pseudonymization salt.
///
/// A salt is mandatory rather than optional. Hashing a seven-digit student id
/// without a key is not de-identification — the entire space can be enumerated in
/// under a second — so silently defaulting to an unkeyed hash would hand back a
/// file that looks anonymous and is not.
pub(crate) fn read_salt(path: Option<&Path>) -> Result<Vec<u8>> {
let Some(path) = path else {
return Err(Error::usage(
"--pseudonymize needs --salt-file. Generate one with `openssl rand -hex 32 > \
~/.coursebank-salt` and keep it OUT of the course repository: without a secret key, \
hashed student ids can be reversed by brute force in under a second"
.to_string(),
));
};
let salt = std::fs::read(path).map_err(|e| Error::io(path, e))?;
let trimmed: Vec<u8> = salt
.into_iter()
.filter(|b| !b.is_ascii_whitespace())
.collect();
if trimmed.len() < 16 {
return Err(Error::usage(format!(
"the salt in {} is only {} byte(s); use at least 16",
path.display(),
trimmed.len()
)));
}
Ok(trimmed)
}
/// Parses `1=6,2=8` into a level map.
pub(crate) fn parse_level_map(pairs: &[String]) -> Result<BTreeMap<Level, usize>> {
let mut out = BTreeMap::new();
for pair in pairs {
let (key, value) = pair.split_once('=').ok_or_else(|| {
Error::usage(format!("expected LEVEL=COUNT, got `{pair}` (e.g. 3=10)"))
})?;
let code: u8 = key
.trim()
.parse()
.map_err(|_| Error::usage(format!("`{key}` is not a level number 1-5")))?;
let level = Level::from_code(code)
.ok_or_else(|| Error::usage(format!("`{code}` is not a level number 1-5")))?;
let count: usize = value
.trim()
.parse()
.map_err(|_| Error::usage(format!("`{value}` is not a count")))?;
out.insert(level, count);
}
Ok(out)
}
/// Parses `lo-a=2,lo-b=1` into a string map.
pub(crate) fn parse_string_map(pairs: &[String]) -> Result<BTreeMap<String, usize>> {
let mut out = BTreeMap::new();
for pair in pairs {
let (key, value) = pair
.split_once('=')
.ok_or_else(|| Error::usage(format!("expected ID=COUNT, got `{pair}`")))?;
let count: usize = value
.trim()
.parse()
.map_err(|_| Error::usage(format!("`{value}` is not a count")))?;
out.insert(key.trim().to_string(), count);
}
Ok(out)
}
/// Prints a record summary.
pub(crate) fn print_record(catalog: &Catalog, record: &AssessmentFile) {
println!(
"{} — {} ({})",
record.assessment.id,
record.assessment.title,
record.assessment.kind.as_str()
);
println!(
"{} item(s), {:.1} point(s){}",
record.items.iter().filter(|p| !p.bonus).count(),
record.total_points(catalog.course.policy.points_per_item),
record
.assessment
.date
.map(|d| format!(", {d}"))
.unwrap_or_default()
);
println!(
"estimated {:.0} minutes of working time",
catalog.estimated_minutes(record)
);
println!("\nBy level:");
for (level, count) in record.level_counts() {
println!(" {} {:<12} {count}", level.code(), level.name());
}
println!("\n{:>3} {:<34} {:<6} {:>6}", "#", "ITEM", "KEY", "POINTS");
for placement in &record.items {
println!(
"{:>3} {:<34} {:<6} {:>6.1}{}",
placement.number,
truncate(&placement.item, 34),
placement.key.join(""),
placement
.points
.unwrap_or(catalog.course.policy.points_per_item),
if placement.bonus { " (bonus)" } else { "" }
);
}
}
/// Renders an assessment as Markdown, for review before printing.
pub(crate) fn markdown_export(
catalog: &Catalog,
record: &AssessmentFile,
with_key: bool,
) -> Result<String> {
let mut out = format!(
"# {}\n\n{} · {}\n\n",
record.assessment.title,
catalog.course.course.code,
record
.assessment
.date
.map(|d| d.to_string())
.unwrap_or_else(|| "undated".into())
);
for placement in &record.items {
let entry = catalog.require(&placement.item)?;
let item = &entry.item;
out.push_str(&format!(
"## {}. {}{}\n\n{}\n\n",
placement.number,
if placement.bonus { "(bonus) " } else { "" },
item.display_title(),
item.stem
));
for option in &item.options {
let marker = if with_key && option.correct {
""
} else {
""
};
out.push_str(&format!("- **{}.** {}{marker}\n", option.id, option.text));
}
out.push('\n');
if with_key {
if let Some(design) = &item.design {
if let Some(rationale) = &design.rationale {
out.push_str(&format!("> {rationale}\n\n"));
}
}
for option in &item.options {
if let Some(explanation) = &option.explanation {
out.push_str(&format!("- {}: {explanation}\n", option.id));
}
}
out.push('\n');
}
}
Ok(out)
}
/// Truncates a string to a width, with an ellipsis.
pub(crate) fn truncate(s: &str, width: usize) -> String {
if s.chars().count() <= width {
return s.to_string();
}
let kept: String = s.chars().take(width.saturating_sub(1)).collect();
format!("{kept}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn level_maps_parse() {
let map = parse_level_map(&["1=6".to_string(), "3=10".to_string()]).unwrap();
assert_eq!(map.get(&Level::Remember), Some(&6));
assert_eq!(map.get(&Level::Apply), Some(&10));
assert_eq!(map.len(), 2);
}
#[test]
fn bad_level_maps_explain_themselves() {
let err = parse_level_map(&["nonsense".to_string()]).unwrap_err();
assert!(err.to_string().contains("LEVEL=COUNT"));
let err = parse_level_map(&["9=1".to_string()]).unwrap_err();
assert!(err.to_string().contains("level number"));
let err = parse_level_map(&["1=many".to_string()]).unwrap_err();
assert!(err.to_string().contains("not a count"));
}
#[test]
fn objective_requirements_parse() {
let map = parse_string_map(&["lo-a=2".to_string(), " lo-b = 1 ".to_string()]).unwrap();
assert_eq!(map.get("lo-a"), Some(&2));
assert_eq!(map.get("lo-b"), Some(&1));
}
#[test]
fn truncation_keeps_the_width() {
assert_eq!(truncate("short", 10), "short");
assert_eq!(truncate("abcdefghij", 5).chars().count(), 5);
assert!(truncate("abcdefghij", 5).ends_with('…'));
}
#[test]
fn pseudonymizing_without_a_salt_is_refused() {
let err = read_salt(None).unwrap_err();
assert!(err.to_string().contains("salt-file"));
assert!(
err.to_string().contains("brute force"),
"the message must explain why, not just what"
);
}
#[test]
fn a_short_salt_is_refused() {
let dir = std::env::temp_dir().join(format!("cb-salt-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("weak.salt");
std::fs::write(&path, b"tooshort\n").unwrap();
let err = read_salt(Some(&path)).unwrap_err();
assert!(err.to_string().contains("at least 16"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_good_salt_is_read_and_trimmed() {
let dir = std::env::temp_dir().join(format!("cb-salt-ok-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("good.salt");
std::fs::write(&path, b"0123456789abcdef0123456789abcdef\n").unwrap();
let salt = read_salt(Some(&path)).unwrap();
assert_eq!(salt.len(), 32, "whitespace is stripped");
std::fs::remove_dir_all(&dir).ok();
}
}
+124
View File
@@ -0,0 +1,124 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! # coursebank
//!
//! A tool for running the assessment side of a course as version-controlled data.
//!
//! The premise is that the artifacts you already produce (questions, exams,
//! grading exports) are worth treating as a dataset rather than as a pile of
//! documents. Once they are, several things you cannot otherwise do become
//! routine: knowing which questions actually discriminate, catching a poorly worded
//! item from the pattern of who chose which distractor, telling a student which
//! misconception their specific wrong answer indicates, and never accidentally
//! reusing the same question three terms in a row.
//!
//! ## The four kinds of file
//!
//! | File | Holds | Written by |
//! |:--|:--|:--|
//! | `course.yaml` | identity, policy, objectives, lectures | you |
//! | `banks/*.yaml` | items, with design intent and pooled statistics | you, then `calibrate` |
//! | `assessments/*.yaml` | what was given, to whom, when | `assemble`, then you |
//! | `data/*.parquet` | one row per student per item | `ingest` |
//!
//! Three of the four are hand-editable YAML meant to be reviewed in a pull request.
//! Only the response data is machine-only, and it is stored in an open columnar
//! format so pandas, polars, R, and DuckDB can all read it without this tool.
//!
//! ## The loop
//!
//! ```text
//! author items ──▶ validate ──▶ lint ──▶ assemble ──▶ export ──┐
//! ▲ │
//! │ administer
//! │ │
//! calibrate ◀── analyze ◀── ingest ◀───────────────────────────┘
//! │
//! └──▶ report (students and cohort)
//! ```
//!
//! The arrow back from `calibrate` to authoring is the point of the whole design.
//! Statistics written onto the item are there the next time you consider using it,
//! and they accumulate across terms: twenty-four students tells you very little,
//! but ninety-six across four terms tells you something real.
//!
//! ## Design commitments
//!
//! Assessment records are the single source of truth for reuse history. There
//! is no separate ledger file, because a ledger duplicates what the records must
//! already get right and then drifts from it. [`history::History`] derives usage
//! by scanning the records.
//!
//! Fingerprints cover only what a student saw. Retag an item's metadata and its
//! pooled statistics stay valid; reword the stem and they are marked stale. See
//! [`item::Item::fingerprint`].
//!
//! Validation reports everything at once. Fixing one typo per run is not a
//! workflow. [`error::Error::Invalid`] carries a list.
//!
//! Validation and linting are separate. [`bank::BankFile::validate`] enforces
//! what must be true; [`lint`] advises on what is usually a mistake, and every rule
//! has a code you can silence.
//!
//! Small samples are labelled as such. Every statistic computed from a class of
//! twenty-five is reported with the caveat it deserves rather than three decimal
//! places of false precision.
//!
//! ## Dependency posture
//!
//! Deliberately small: serde, a YAML parser, clap, thiserror, and csv, plus arrow
//! and parquet behind a default-on feature that can be switched off. Dates, PRNG,
//! hashing, ZIP writing, and the psychometrics are implemented here rather than
//! pulled in — see [`date`], [`rng`], [`hash`], [`zipfile`], [`irt`]. For a tool
//! whose job is to still open a course repository in five years, that tradeoff
//! favours fewer moving parts.
//!
//! ## Where to start
//!
//! This page describes the shape of the crate. For a walkthrough, [`guide`] holds
//! setup, authoring, and tutorials, starting with [`guide::setup`].
#![warn(missing_docs)]
#![forbid(unsafe_code)]
#![deny(rustdoc::broken_intra_doc_links)]
#![warn(rustdoc::invalid_codeblock_attributes)]
#![warn(rustdoc::invalid_html_tags)]
#![warn(rustdoc::bare_urls)]
#![warn(rustdoc::private_intra_doc_links)]
#![cfg_attr(docsrs, feature(doc_cfg))]
pub mod analysis;
pub mod authoring;
pub mod data;
pub mod error;
pub mod export;
pub mod guide;
pub mod model;
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 authoring::{jsonschema, lint, select};
#[cfg(feature = "parquet")]
pub use data::store_parquet;
pub use data::{canvas, gradescope, responses, store};
pub use analysis::{calibrate, classical, irt, students};
pub use export::{qti, report, typst};
pub use catalog::Catalog;
pub use course::{CourseFile, SCHEMA_VERSION};
pub use error::{Error, Result};
pub use history::History;
pub use item::Item;
pub use layout::Layout;
pub use taxonomy::{CognitiveProcess, ErrorType, Flag, Format, Level, Status};
/// Version of package.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
+56
View File
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! The `coursebank` command-line interface.
//!
//! Command names follow the workflow rather than the data model: you `assemble` an
//! exam, `ingest` the grading export, `analyze` it, and `report`. Every command that
//! modifies a bank prints what it would change and requires `--apply` to do it,
//! because these files are reviewed artifacts in a git repository and a silent
//! rewrite is not something you want to discover in a diff later.
//!
//! Exit codes are meaningful for scripting and CI: `0` for success, `1` for a
//! failure, `2` when validation or linting found problems. That last one is the
//! reason a course repository can have a pre-commit hook.
//!
//! # Module layout
//!
//! `main.rs` is deliberately thin. The pieces live in sibling modules:
//!
//! - [`cli`] — the `clap` argument model (every flag, subcommand, and the small
//! conversions from CLI-facing enums into the library's domain enums).
//! - [`commands`] — the [`run`](commands::run) dispatcher plus one submodule per
//! workflow stage, each holding the handlers for its subcommands.
//! - [`helpers`] — shared plumbing used by more than one handler: loading the
//! catalog, resolving records and forms, parsing `KEY=VALUE` flags, and the
//! text formatting used by the various listings.
//! - [`import`] — the one-off legacy-JSON importer, kept apart because it is large
//! and touched rarely.
mod cli;
mod commands;
mod helpers;
use std::process::ExitCode;
use clap::Parser;
use crate::cli::Cli;
use crate::commands::{Outcome, run};
/// Parses the command line, runs the requested command, and maps its result onto
/// a process exit code.
///
/// See the crate-level documentation for the meaning of each code.
fn main() -> ExitCode {
let cli = Cli::parse();
match run(&cli) {
Ok(Outcome::Ok) => ExitCode::SUCCESS,
Ok(Outcome::Findings) => ExitCode::from(2),
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
+34
View File
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! The data model: the four kinds of file, and the taxonomy they are written against.
//!
//! These modules define what a course is as far as this tool is concerned, and
//! they own all validation of it. Everything else in the crate reads these types.
//!
//! The dependency order runs downward and never back up:
//!
//! ```text
//! taxonomy levels, cognitive processes, error types, status, flags
//! │
//! course course.yaml: identity, policy, objectives, lectures, stimuli
//! │
//! item one question: stem, options, design intent, calibration
//! │
//! bank a file of items, plus defaults applied on load
//! │
//! catalog every bank resolved against the course; coverage and gaps
//! │
//! assessment what was given, to whom, when — and the reuse history derived
//! by scanning those records rather than kept in a ledger
//! ```
pub mod assessment;
pub mod bank;
pub mod catalog;
pub mod course;
pub mod history;
pub mod item;
pub mod layout;
pub mod taxonomy;
+627
View File
@@ -0,0 +1,627 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! The assessment record: what you actually asked, in what order, on what date.
//!
//! This file is the hinge of the whole tool, and it is worth being explicit about
//! why it exists as a separate artifact.
//!
//! Grading exports do not know about your item bank. Gradescope gives you
//! `14.csv`; Canvas gives you a column header. Both identify questions by
//! *position on a form*. An item bank identifies questions by stable id. The
//! assessment record is the only place those two namespaces meet, and without it
//! there is no way to say that question 14 of Exam 4 was
//! `docking::q-scoring-003` at version 2.
//!
//! Recording it also makes usage history free. Rather than maintaining a separate
//! ledger that can drift out of sync with reality, `coursebank usage` scans the
//! assessment records: an item was used exactly when it appears on a record. The
//! records are the source of truth, and they are small, readable, and diffable.
//!
//! Each placement stores the resolved key and content fingerprint *as used*. A
//! year later, when the item has been reworded twice, you can still see what the
//! students in front of you were asked.
use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::course::SCHEMA_VERSION;
use crate::date::Date;
use crate::error::Result;
use crate::taxonomy::Level;
use crate::yaml;
/// A whole assessment record file.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AssessmentFile {
/// Schema version this file targets.
#[serde(
default = "default_version",
deserialize_with = "yaml::flexible_string"
)]
pub schema_version: String,
/// Identity and administration details.
pub assessment: Assessment,
/// The blueprint this was assembled against, when it was assembled by the
/// tool. Keeping it lets you check the form you shipped against the design
/// you intended.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blueprint: Option<Blueprint>,
/// Alternate forms, each a permutation of the same items.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub forms: Vec<Form>,
/// The items, in printed order.
#[serde(default)]
pub items: Vec<Placement>,
}
/// Assessment identity and administration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Assessment {
/// Stable id, e.g. `exam-4-2026s`. Response tables carry this.
pub id: String,
/// Human title as printed, e.g. `Exam 4`.
pub title: String,
/// The term this administration belongs to. Items outlive terms, so the term
/// lives here rather than on the item.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub term: Option<String>,
/// What kind of assessment this is.
#[serde(default = "default_kind")]
pub kind: Kind,
/// When it was administered.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub date: Option<Date>,
/// Where it was administered.
#[serde(default = "default_platform")]
pub platform: Platform,
/// Time allowed, in minutes. Compared against the summed expected time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub minutes_allowed: Option<f64>,
/// Attempts allowed; `-1` means unlimited. Only meaningful online.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attempts: Option<i64>,
/// Whether the platform should shuffle options.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shuffle: Option<bool>,
/// How repeated attempts are scored.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scoring_policy: Option<ScoringPolicy>,
/// Instructions printed at the top of a paper form.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
/// Notes to yourself about this administration.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
/// What kind of assessment a record describes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
/// A summative in-term exam.
Exam,
/// A short graded check.
Quiz,
/// Graded work done outside class.
Homework,
/// Ungraded practice.
Practice,
/// A cumulative final.
Final,
}
impl Kind {
/// The token used in YAML and in file names.
pub fn as_str(self) -> &'static str {
match self {
Kind::Exam => "exam",
Kind::Quiz => "quiz",
Kind::Homework => "homework",
Kind::Practice => "practice",
Kind::Final => "final",
}
}
/// Whether results from this kind should feed item calibration.
///
/// Practice work is excluded by default: it is usually untimed, open book,
/// and attempted more than once, so pooling it with exam data would bias
/// every difficulty estimate downward.
pub fn counts_for_calibration(self) -> bool {
!matches!(self, Kind::Practice)
}
}
/// Where an assessment was administered, which determines the export format and
/// the shape of the grading data that comes back.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Platform {
/// Printed and scanned, graded in Gradescope.
Paper,
/// A Canvas quiz.
Canvas,
/// Delivered on paper but graded elsewhere.
Other,
}
/// How repeated attempts are scored.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScoringPolicy {
/// Keep the best attempt.
KeepHighest,
/// Keep the most recent attempt.
KeepLatest,
}
impl ScoringPolicy {
/// The token Canvas expects in a QTI package.
pub fn as_str(self) -> &'static str {
match self {
ScoringPolicy::KeepHighest => "keep_highest",
ScoringPolicy::KeepLatest => "keep_latest",
}
}
}
/// The design an assessment was assembled against.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Blueprint {
/// How many scored items to draw at each level.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub level_counts: BTreeMap<Level, usize>,
/// How many bonus items to draw at each level.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub bonus_counts: BTreeMap<Level, usize>,
/// Objectives that must appear, with a minimum item count each.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub objective_minimums: BTreeMap<String, usize>,
/// Restrict the draw to these lectures.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lectures: Vec<String>,
/// Restrict the draw to these topics.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub topics: Vec<String>,
/// Restrict the draw to these banks.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub banks: Vec<String>,
/// The most items any one bank may contribute, so a form is not dominated by
/// whichever topic you happened to write the most questions about.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_per_bank: Option<usize>,
/// Do not reuse an item used within this many days.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cooldown_days: Option<i64>,
/// The seed used, so the draw can be reproduced exactly.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
}
impl Blueprint {
/// Total scored items requested.
pub fn scored_total(&self) -> usize {
self.level_counts.values().sum()
}
/// Total bonus items requested.
pub fn bonus_total(&self) -> usize {
self.bonus_counts.values().sum()
}
}
/// One alternate form of the same assessment.
///
/// A form does not change which items appear, only their order and the order of
/// their options, so every form measures the same thing and one answer key
/// generator serves them all.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Form {
/// Form label, e.g. `A`.
pub id: String,
/// Seed for the permutation. Reproducing a printed form requires it.
pub seed: u64,
/// Whether item order is permuted.
#[serde(default = "yes")]
pub shuffle_items: bool,
/// Whether option order is permuted within each item.
#[serde(default = "yes")]
pub shuffle_options: bool,
}
/// One item as placed on the form.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Placement {
/// Printed question number. This is the join key to grading exports, which
/// is the entire reason this record exists.
pub number: u32,
/// The item's global id, `bank::item`.
pub item: String,
/// The item version used.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<u32>,
/// The content fingerprint as used, so later edits are detectable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>,
/// Points as administered.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub points: Option<f64>,
/// Whether it was scored as bonus here.
#[serde(default, skip_serializing_if = "is_false")]
pub bonus: bool,
/// The keyed letters as administered.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub key: Vec<String>,
/// The level as administered, denormalized so a record reads standalone.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<Level>,
/// Objectives as administered, denormalized for the same reason.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec<String>,
/// Credit awarded to non-keyed options after the fact, keyed by letter.
///
/// When item analysis or a student challenge leads you to credit a
/// distractor, recording it here keeps the rescoring decision with the
/// administration it applies to instead of quietly editing the item.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub credit_overrides: BTreeMap<String, f64>,
/// Set when an item was dropped from scoring after administration.
#[serde(default, skip_serializing_if = "is_false")]
pub dropped: bool,
}
impl AssessmentFile {
/// Loads an assessment record.
///
/// # Arguments
///
/// * `path` - the YAML file.
///
/// # Returns
///
/// The parsed record.
///
/// # Errors
///
/// Returns a load error.
pub fn load(path: &Path) -> Result<AssessmentFile> {
yaml::read(path)
}
/// Writes the record back out.
///
/// # Arguments
///
/// * `path` - the destination.
///
/// # Errors
///
/// Returns [`crate::error::Error::Io`] on a write failure.
pub fn save(&self, path: &Path) -> Result<()> {
yaml::write(path, self)
}
/// Loads every assessment record in a directory, sorted by date then id.
///
/// # Arguments
///
/// * `dir` - the assessments directory.
///
/// # Returns
///
/// The records, empty when the directory does not exist.
///
/// # Errors
///
/// Propagates load errors.
pub fn load_all(dir: &Path) -> Result<Vec<AssessmentFile>> {
let mut out = Vec::new();
for path in yaml::list_yaml(dir)? {
out.push(AssessmentFile::load(&path)?);
}
out.sort_by(|a, b| {
a.assessment
.date
.cmp(&b.assessment.date)
.then(a.assessment.id.cmp(&b.assessment.id))
});
Ok(out)
}
/// The placement at a printed question number.
///
/// # Arguments
///
/// * `number` - the printed number.
///
/// # Returns
///
/// The placement, or `None`.
pub fn placement(&self, number: u32) -> Option<&Placement> {
self.items.iter().find(|p| p.number == number)
}
/// Total scored points, excluding bonus and dropped items.
///
/// # Arguments
///
/// * `default_points` - the course policy default.
///
/// # Returns
///
/// The total.
pub fn total_points(&self, default_points: f64) -> f64 {
self.items
.iter()
.filter(|p| !p.bonus && !p.dropped)
.map(|p| p.points.unwrap_or(default_points))
.sum()
}
/// Counts of scored placements by level.
///
/// # Returns
///
/// A map from level to count.
pub fn level_counts(&self) -> BTreeMap<Level, usize> {
let mut out: BTreeMap<Level, usize> = BTreeMap::new();
for p in self.items.iter().filter(|p| !p.bonus) {
if let Some(l) = p.level {
*out.entry(l).or_insert(0) += 1;
}
}
out
}
/// Checks the record for internal problems and for drift from the bank.
///
/// The drift check is the valuable part: if an item was reworded after this
/// assessment was given, the fingerprints disagree, and any analysis that
/// pools this administration with a later one is comparing two questions.
///
/// # Arguments
///
/// * `catalog` - the loaded course, for resolving item references.
///
/// # Returns
///
/// Every problem found.
pub fn validate(&self) -> Vec<String> {
let mut issues = Vec::new();
if self.assessment.id.trim().is_empty() {
issues.push("assessment.id is empty".into());
}
if self.items.is_empty() {
issues.push("the record lists no items".into());
}
let mut numbers: BTreeMap<u32, usize> = BTreeMap::new();
for p in &self.items {
*numbers.entry(p.number).or_insert(0) += 1;
}
for (n, count) in &numbers {
if *count > 1 {
issues.push(format!("question number {n} is used {count} times"));
}
}
// Gaps are legal but almost always a mistake, since grading exports are
// numbered contiguously.
let mut sorted: Vec<u32> = numbers.keys().copied().collect();
sorted.sort_unstable();
for (i, n) in sorted.iter().enumerate() {
let expected = i as u32 + 1;
if *n != expected {
issues.push(format!(
"question numbers are not contiguous from 1; expected {expected}, found {n}"
));
break;
}
}
let mut seen_items: BTreeMap<&str, u32> = BTreeMap::new();
for p in &self.items {
if let Some(first) = seen_items.get(p.item.as_str()) {
issues.push(format!(
"item `{}` appears twice, at questions {first} and {}",
p.item, p.number
));
} else {
seen_items.insert(p.item.as_str(), p.number);
}
for (letter, credit) in &p.credit_overrides {
if !(0.0..=1.0).contains(credit) {
issues.push(format!(
"question {}: credit override for `{letter}` must be in [0, 1], got {credit}",
p.number
));
}
}
}
let mut form_ids: Vec<&str> = Vec::new();
for form in &self.forms {
if form_ids.contains(&form.id.as_str()) {
issues.push(format!("duplicate form id `{}`", form.id));
}
form_ids.push(&form.id);
}
issues
}
/// A skeleton record for `coursebank assessment new`.
///
/// # Arguments
///
/// * `id` - the assessment id.
/// * `title` - the printed title.
/// * `kind` - the kind of assessment.
///
/// # Returns
///
/// A record with no items.
pub fn skeleton(id: &str, title: &str, kind: Kind) -> AssessmentFile {
AssessmentFile {
schema_version: SCHEMA_VERSION.to_string(),
assessment: Assessment {
id: id.to_string(),
title: title.to_string(),
term: None,
kind,
date: Some(Date::today()),
platform: Platform::Paper,
minutes_allowed: None,
attempts: None,
shuffle: None,
scoring_policy: None,
instructions: None,
notes: None,
},
blueprint: None,
forms: Vec::new(),
items: Vec::new(),
}
}
}
/// One recorded appearance of an item, derived from the assessment records.
#[derive(Debug, Clone)]
pub struct Usage {
/// The item's global id.
pub item: String,
/// The assessment id it appeared on.
pub assessment: String,
/// The assessment title.
pub title: String,
/// The kind of assessment.
pub kind: Kind,
/// When it was administered, when recorded.
pub date: Option<Date>,
/// The printed question number.
pub number: u32,
/// The fingerprint as used.
pub fingerprint: Option<String>,
}
fn default_version() -> String {
SCHEMA_VERSION.to_string()
}
fn default_kind() -> Kind {
Kind::Exam
}
fn default_platform() -> Platform {
Platform::Paper
}
fn yes() -> bool {
true
}
fn is_false(b: &bool) -> bool {
!*b
}
#[cfg(test)]
mod tests {
use super::*;
fn record(src: &str) -> AssessmentFile {
serde_yaml_ng::from_str(src).expect("assessment parses")
}
const SIMPLE: &str = r#"
assessment:
id: exam-4-2026s
title: Exam 4
kind: exam
date: 2026-04-23
platform: paper
items:
- { number: 1, item: "b1::q-a-001", points: 1.5, key: [B], level: 1 }
- { number: 2, item: "b1::q-a-002", points: 1.5, key: [C], level: 3 }
- { number: 3, item: "b1::q-a-003", points: 1.5, bonus: true, key: [D], level: 5 }
"#;
#[test]
fn parses_and_totals_only_scored_points() {
let a = record(SIMPLE);
assert_eq!(a.items.len(), 3);
assert_eq!(a.total_points(1.0), 3.0, "bonus is excluded");
assert_eq!(a.placement(2).unwrap().item, "b1::q-a-002");
assert!(a.validate().is_empty(), "{:?}", a.validate());
}
#[test]
fn level_counts_skip_bonus() {
let counts = record(SIMPLE).level_counts();
assert_eq!(counts.get(&Level::Remember), Some(&1));
assert_eq!(counts.get(&Level::Apply), Some(&1));
assert_eq!(counts.get(&Level::Create), None);
}
#[test]
fn catches_duplicate_numbers_and_repeated_items() {
let a = record(
r#"
assessment: { id: x, title: X }
items:
- { number: 1, item: "b::q-1" }
- { number: 1, item: "b::q-1" }
"#,
);
let issues = a.validate();
assert!(issues.iter().any(|i| i.contains("used 2 times")));
assert!(issues.iter().any(|i| i.contains("appears twice")));
}
#[test]
fn catches_non_contiguous_numbering() {
let a = record(
r#"
assessment: { id: x, title: X }
items:
- { number: 1, item: "b::q-1" }
- { number: 3, item: "b::q-2" }
"#,
);
assert!(a.validate().iter().any(|i| i.contains("not contiguous")));
}
#[test]
fn rejects_out_of_range_credit_overrides() {
let a = record(
r#"
assessment: { id: x, title: X }
items:
- { number: 1, item: "b::q-1", credit_overrides: { B: 1.5 } }
"#,
);
assert!(a.validate().iter().any(|i| i.contains("must be in [0, 1]")));
}
#[test]
fn blueprint_totals() {
let b: Blueprint = serde_yaml_ng::from_str(
r#"
level_counts: { 1: 6, 2: 10, 3: 12, 4: 8 }
bonus_counts: { 5: 2 }
"#,
)
.unwrap();
assert_eq!(b.scored_total(), 36);
assert_eq!(b.bonus_total(), 2);
}
}
+914
View File
@@ -0,0 +1,914 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! The bank file: a collection of items scoped to a topic or a lecture.
//!
//! One bank per topic (or per lecture, if that suits how you teach) is the unit
//! of authoring. Banks are small enough to review in a pull request, they let
//! two people write questions without colliding, and `bank.scope` records what
//! the file is *for* so `coursebank catalog` can tell you that you have eleven
//! items on enzyme kinetics and none on regulation.
//!
//! Validation here is split in two on purpose. [`BankFile::validate`] checks what
//! must be true for the file to be usable at all: ids are unique, a keyed answer
//! exists, an approved item is fully specified, a level and its cognitive process
//! agree. The softer question of whether an item is *well written* lives in
//! [`crate::lint`], because those checks are advisory and you should be able to
//! ship a file that trips a few of them.
use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::course::{CourseFile, SCHEMA_VERSION};
use crate::date::Date;
use crate::error::Result;
use crate::item::Item;
use crate::taxonomy::{Format, Level, Status};
use crate::yaml;
/// A whole bank file.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BankFile {
/// Schema version this file targets.
#[serde(
default = "default_version",
deserialize_with = "yaml::flexible_string"
)]
pub schema_version: String,
/// Bank identity and scope.
pub bank: BankMeta,
/// Values applied to every item in the file that does not set its own.
#[serde(default)]
pub defaults: BankDefaults,
/// The items.
#[serde(default)]
pub items: Vec<Item>,
}
/// Bank identity and scope.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BankMeta {
/// Stable id, unique across the course. Item ids are namespaced by it.
pub id: String,
/// Human title.
pub title: String,
/// What this bank covers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// What the bank is scoped to, so coverage can be reported against it.
#[serde(default)]
pub scope: Scope,
/// Who maintains it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub maintainer: Option<String>,
/// When it was created.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created: Option<Date>,
/// When it was last touched.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated: Option<Date>,
}
/// What a bank is scoped to.
///
/// A bank may be scoped by lecture, by objective, by topic, or by none of them.
/// Declaring the scope is what lets the catalog report *gaps*: it can only tell
/// you that lecture 12 has no Apply-level items if it knows lecture 12 is
/// supposed to be covered here.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Scope {
/// Lectures this bank draws from.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lectures: Vec<String>,
/// Objectives this bank is responsible for covering.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec<String>,
/// Units this bank belongs to.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub units: Vec<String>,
/// Topics this bank is about.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub topics: Vec<String>,
}
/// Per-file defaults, so common metadata is written once.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BankDefaults {
/// Default author for items in this file.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
/// Default point value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub points: Option<f64>,
/// Expected option count; the linter flags items that differ, since an
/// inconsistent option count across a form is itself a cue to students.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub options_per_item: Option<usize>,
/// Topics added to every item in the file.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub topics: Vec<String>,
/// Sources applied to items that declare none.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lectures: Vec<String>,
}
impl BankFile {
/// Loads a bank file from disk.
///
/// # Arguments
///
/// * `path` - the YAML file.
///
/// # Returns
///
/// The parsed bank.
///
/// # Errors
///
/// Returns [`crate::error::Error::Io`] or [`crate::error::Error::Yaml`].
pub fn load(path: &Path) -> Result<BankFile> {
yaml::read(path)
}
/// Writes a bank file back out as YAML.
///
/// Round-tripping loses comments, which is why calibration is written by an
/// explicit `coursebank calibrate` step rather than as a side effect of
/// anything else: you should be able to see the diff it produces.
///
/// # Arguments
///
/// * `path` - the destination.
///
/// # Errors
///
/// Returns [`crate::error::Error::Io`] on a write failure.
pub fn save(&self, path: &Path) -> Result<()> {
yaml::write(path, self)
}
/// Applies file defaults to items that omit the corresponding field.
///
/// Called after loading so the rest of the crate never has to think about
/// defaults again.
pub fn apply_defaults(&mut self) {
let d = self.defaults.clone();
for item in &mut self.items {
if item.author.is_none() {
item.author = d.author.clone();
}
if item.points.is_none() {
item.points = d.points;
}
for t in &d.topics {
if !item.topics.contains(t) {
item.topics.push(t.clone());
}
}
if item.sources.is_empty() {
for lec in &d.lectures {
item.sources.push(crate::item::Source {
lecture: lec.clone(),
slides: Vec::new(),
readings: Vec::new(),
recording_seconds: None,
});
}
}
}
}
/// Loads a bank and applies its defaults.
///
/// # Arguments
///
/// * `path` - the YAML file.
///
/// # Returns
///
/// The parsed bank with defaults resolved.
///
/// # Errors
///
/// Propagates load errors.
pub fn load_resolved(path: &Path) -> Result<BankFile> {
let mut b = BankFile::load(path)?;
b.apply_defaults();
Ok(b)
}
/// Checks every invariant that must hold for the file to be usable.
///
/// Returns all problems rather than the first, so one run fixes one file.
/// When a course file is supplied, cross-file references are checked too.
///
/// # Arguments
///
/// * `course` - the course registry, for resolving objective and lecture
/// references. Pass `None` to check only what is local to the file.
///
/// # Returns
///
/// Every problem found, empty when the file is sound.
pub fn validate(&self, course: Option<&CourseFile>) -> Vec<String> {
let mut issues = Vec::new();
if self.bank.id.trim().is_empty() {
issues.push("bank.id is empty".into());
}
if self.bank.title.trim().is_empty() {
issues.push("bank.title is empty".into());
}
if let (Some(created), Some(updated)) = (self.bank.created, self.bank.updated) {
if updated < created {
issues.push(format!(
"bank.updated ({updated}) is before bank.created ({created})"
));
}
}
// Duplicate item ids inside the file.
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
for it in &self.items {
*counts.entry(it.id.as_str()).or_insert(0) += 1;
}
for (id, n) in &counts {
if *n > 1 {
issues.push(format!("duplicate item id `{id}` appears {n} times"));
}
}
if let Some(c) = course {
for lec in &self.bank.scope.lectures {
if !c.lectures.contains_key(lec) {
issues.push(format!("bank.scope: unknown lecture `{lec}`"));
}
}
for lo in &self.bank.scope.learning_objectives {
if !c.learning_objectives.contains_key(lo) {
issues.push(format!("bank.scope: unknown learning objective `{lo}`"));
}
}
}
for it in &self.items {
issues.extend(
validate_item(it, course, self.defaults.options_per_item)
.into_iter()
.map(|m| format!("{}: {m}", it.id)),
);
}
issues
}
/// Items that may be placed on a graded assessment.
///
/// # Returns
///
/// References to approved, unretired items.
pub fn assemblable(&self) -> Vec<&Item> {
self.items.iter().filter(|i| i.is_assemblable()).collect()
}
/// Counts of assemblable, non-bonus items by level.
///
/// This is the five-tuple you check a blueprint against.
///
/// # Returns
///
/// A map from level to count.
pub fn level_counts(&self) -> BTreeMap<Level, usize> {
let mut out: BTreeMap<Level, usize> = Level::ALL.iter().map(|l| (*l, 0)).collect();
for it in self.items.iter().filter(|i| i.is_assemblable() && !i.bonus) {
*out.entry(it.level).or_insert(0) += 1;
}
out
}
/// A skeleton bank file for `coursebank bank new`.
///
/// # Arguments
///
/// * `id` - the bank id.
/// * `title` - the bank title.
///
/// # Returns
///
/// A bank with no items.
pub fn skeleton(id: &str, title: &str) -> BankFile {
BankFile {
schema_version: SCHEMA_VERSION.to_string(),
bank: BankMeta {
id: id.to_string(),
title: title.to_string(),
description: None,
scope: Scope::default(),
maintainer: None,
created: Some(Date::today()),
updated: Some(Date::today()),
},
defaults: BankDefaults::default(),
items: Vec::new(),
}
}
}
/// Validates one item.
///
/// # Arguments
///
/// * `it` - the item.
/// * `course` - the course registry, when available.
/// * `expected_options` - the file's declared option count, when set.
///
/// # Returns
///
/// Problems found, without the item id prefix.
fn validate_item(
it: &Item,
course: Option<&CourseFile>,
expected_options: Option<usize>,
) -> Vec<String> {
let mut issues = Vec::new();
if it.id.trim().is_empty() {
issues.push("empty id".into());
}
if it.stem.trim().is_empty() {
issues.push("empty stem".into());
}
if it.version == 0 {
issues.push("version must be at least 1".into());
}
// --- options -----------------------------------------------------------
if it.options.len() < 2 {
issues.push(format!(
"needs at least 2 options, has {}",
it.options.len()
));
}
let mut seen: Vec<&str> = Vec::new();
for (i, o) in it.options.iter().enumerate() {
let pos = i + 1;
if o.text.trim().is_empty() {
issues.push(format!("option {pos}: empty text"));
}
let letter_ok = o.id.len() == 1
&& o.id
.chars()
.next()
.map(|c| c.is_ascii_uppercase() && c <= 'H')
.unwrap_or(false);
if !letter_ok {
issues.push(format!(
"option {pos}: id `{}` must be a single letter A through H",
o.id
));
}
if seen.contains(&o.id.as_str()) {
issues.push(format!("option {pos}: duplicate option id `{}`", o.id));
}
seen.push(&o.id);
let credit = o.credit();
if !(0.0..=1.0).contains(&credit) {
issues.push(format!(
"option {}: credit must be between 0 and 1, got {credit}",
o.id
));
}
// Partial credit must be argued for in writing, not remembered.
if o.is_partial() && o.defense.is_none() {
issues.push(format!(
"option {}: awards credit {credit} but gives no `defense`",
o.id
));
}
if o.is_partial() && !o.defensible {
issues.push(format!(
"option {}: awards credit {credit} but is not marked `defensible: true`",
o.id
));
}
if o.correct && credit == 0.0 {
issues.push(format!(
"option {}: keyed correct but earns no credit",
o.id
));
}
}
if let Some(n) = expected_options {
if it.options.len() != n && !it.options.is_empty() {
issues.push(format!(
"has {} options but the bank declares {n} per item",
it.options.len()
));
}
}
// --- key ---------------------------------------------------------------
let keys = it.key_indices();
match it.format {
Format::SingleBestAnswer => {
if keys.len() != 1 {
issues.push(format!(
"single_best_answer needs exactly one keyed option, has {}",
keys.len()
));
}
}
Format::MultipleResponse => {
if keys.is_empty() {
issues.push("multiple_response needs at least one keyed option".into());
}
if keys.len() == it.options.len() {
issues.push("multiple_response keys every option, so it asks nothing".into());
}
}
Format::TrueFalse => {
if it.options.len() != 2 {
issues.push(format!(
"true_false needs exactly 2 options, has {}",
it.options.len()
));
}
if keys.len() != 1 {
issues.push("true_false needs exactly one keyed option".into());
}
}
}
// --- level and process must agree -------------------------------------
if let Some(p) = it.cognitive_process {
if !it.level.allows(p) {
issues.push(format!(
"cognitive_process `{p}` belongs to level {} but the item is level {}",
p.level().code(),
it.level.code()
));
}
}
// --- design plausibility ----------------------------------------------
if let Some(d) = &it.design {
if let Some(x) = d.expected_difficulty {
if !(0.0..=1.0).contains(&x) {
issues.push(format!(
"design.expected_difficulty must be between 0 and 1, got {x}"
));
}
}
if let Some(t) = d.expected_time_seconds {
if t <= 0.0 {
issues.push(format!(
"design.expected_time_seconds must be positive, got {t}"
));
}
}
}
// --- calibration plausibility -----------------------------------------
if let Some(c) = &it.calibration {
if let Some(p) = c.p_value {
if !(0.0..=1.0).contains(&p) {
issues.push(format!(
"calibration.p_value must be between 0 and 1, got {p}"
));
}
}
if let Some(r) = c.point_biserial {
if !(-1.0..=1.0).contains(&r) {
issues.push(format!(
"calibration.point_biserial must be between -1 and 1, got {r}"
));
}
}
for letter in c.option_stats.keys() {
if it.option(letter).is_none() {
issues.push(format!(
"calibration.option_stats has `{letter}`, which is not an option of this item"
));
}
}
if let Some(irt) = &c.irt {
if irt.a <= 0.0 {
issues.push(format!("calibration.irt.a must be positive, got {}", irt.a));
}
if let Some(cp) = irt.c {
if !(0.0..1.0).contains(&cp) {
issues.push(format!("calibration.irt.c must be in [0, 1), got {cp}"));
}
}
}
}
// --- history must be coherent -----------------------------------------
let mut last_version = 0u32;
for (i, h) in it.history.iter().enumerate() {
if h.version <= last_version {
issues.push(format!(
"history entry {} has version {} which does not increase",
i + 1,
h.version
));
}
last_version = h.version;
}
if !it.history.is_empty() && last_version > it.version {
issues.push(format!(
"history records version {last_version} but the item says version {}",
it.version
));
}
// --- retirement -------------------------------------------------------
if it.retired.is_some() && it.status != Status::Retired {
issues.push(format!(
"has a `retired` block but status is `{}`",
it.status
));
}
// --- approval gate ----------------------------------------------------
// Approval is what permits an item onto a graded assessment, so it is the
// right place to require that the item is fully sourced and designed.
if it.status == Status::Approved {
if it.cognitive_process.is_none() {
issues.push("approved items must declare a cognitive_process".into());
}
if it.learning_objectives.is_empty() {
issues.push("approved items must reference at least one learning objective".into());
}
if it.sources.is_empty() {
issues.push("approved items must cite at least one source".into());
}
if it.design.is_none() {
issues.push("approved items must carry a design block".into());
}
}
// --- cross-file references --------------------------------------------
if let Some(c) = course {
for lo in &it.learning_objectives {
match c.learning_objectives.get(lo) {
None => issues.push(format!("unknown learning objective `{lo}`")),
Some(obj) => {
if let Some(ceiling) = obj.level_ceiling {
if it.level > ceiling {
issues.push(format!(
"level {} exceeds the ceiling {} declared for objective `{lo}`",
it.level.code(),
ceiling.code()
));
}
}
if !obj.assessed {
issues.push(format!(
"objective `{lo}` is marked `assessed: false` but this item measures it"
));
}
}
}
}
for s in &it.sources {
if !c.lectures.contains_key(&s.lecture) {
issues.push(format!("unknown lecture `{}`", s.lecture));
}
}
if let Some(st) = &it.stimulus {
if !c.stimuli.contains_key(st) {
issues.push(format!("unknown stimulus `{st}`"));
}
}
if let Some(floor) = c.policy.partial_credit_floor_level {
for o in &it.options {
if o.is_partial() && it.level < floor {
issues.push(format!(
"option {} awards partial credit at level {}, below the course floor of {}",
o.id,
it.level.code(),
floor.code()
));
}
}
}
if !c.policy.allow_partial_credit && it.options.iter().any(|o| o.is_partial()) {
issues.push("awards partial credit, which the course policy disallows".into());
}
}
issues
}
fn default_version() -> String {
SCHEMA_VERSION.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn bank(items_yaml: &str) -> BankFile {
let src = format!("bank:\n id: b\n title: Bank\ndefaults: {{}}\nitems:\n{items_yaml}");
let mut b: BankFile = serde_yaml_ng::from_str(&src).expect("bank parses");
b.apply_defaults();
b
}
#[test]
fn sound_bank_validates_clean() {
let b = bank(
r#"
- id: q-a-001
status: draft
level: 1
stem: What is x?
options:
- { id: A, text: right, correct: true }
- { id: B, text: wrong }
- { id: C, text: wrong too }
"#,
);
assert!(b.validate(None).is_empty(), "{:?}", b.validate(None));
}
#[test]
fn catches_missing_and_multiple_keys() {
let b = bank(
r#"
- id: q-a-001
status: draft
level: 1
stem: s
options:
- { id: A, text: a }
- { id: B, text: b }
- id: q-a-002
status: draft
level: 1
format: single_best_answer
stem: s
options:
- { id: A, text: a, correct: true }
- { id: B, text: b, correct: true }
"#,
);
let issues = b.validate(None);
assert!(
issues
.iter()
.any(|i| i.contains("exactly one keyed option"))
);
assert_eq!(
issues
.iter()
.filter(|i| i.contains("exactly one keyed option"))
.count(),
2
);
}
#[test]
fn catches_duplicate_ids_and_letters() {
let b = bank(
r#"
- id: q-a-001
status: draft
level: 1
stem: s
options:
- { id: A, text: a, correct: true }
- { id: A, text: b }
- id: q-a-001
status: draft
level: 1
stem: s
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
"#,
);
let issues = b.validate(None);
assert!(issues.iter().any(|i| i.contains("duplicate item id")));
assert!(issues.iter().any(|i| i.contains("duplicate option id")));
}
#[test]
fn level_and_process_must_agree() {
let b = bank(
r#"
- id: q-a-001
status: draft
level: 3
cognitive_process: recall
stem: s
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
"#,
);
let issues = b.validate(None);
assert!(
issues.iter().any(|i| i.contains("belongs to level 1")),
"{issues:?}"
);
}
#[test]
fn approval_requires_full_specification() {
let b = bank(
r#"
- id: q-a-001
status: approved
level: 1
stem: s
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
"#,
);
let issues = b.validate(None);
for want in [
"cognitive_process",
"learning objective",
"source",
"design block",
] {
assert!(
issues.iter().any(|i| i.contains(want)),
"expected a complaint about {want}, got {issues:?}"
);
}
}
#[test]
fn partial_credit_needs_a_written_defense() {
let b = bank(
r#"
- id: q-a-001
status: draft
level: 5
stem: s
options:
- { id: A, text: a, correct: true }
- { id: B, text: b, credit: 0.5 }
"#,
);
let issues = b.validate(None);
assert!(issues.iter().any(|i| i.contains("no `defense`")));
assert!(issues.iter().any(|i| i.contains("defensible: true")));
}
#[test]
fn defaults_fill_in_items() {
let src = r#"
bank: { id: b, title: Bank }
defaults:
author: Alex
points: 1.5
topics: [kinetics]
lectures: [L11]
items:
- id: q-a-001
status: draft
level: 1
stem: s
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
- id: q-a-002
status: draft
level: 1
stem: s
author: Someone Else
topics: [kinetics]
sources: [{ lecture: L12 }]
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
"#;
let mut b: BankFile = serde_yaml_ng::from_str(src).unwrap();
b.apply_defaults();
assert_eq!(b.items[0].author.as_deref(), Some("Alex"));
assert_eq!(b.items[0].points, Some(1.5));
assert_eq!(b.items[0].topics, vec!["kinetics"]);
assert_eq!(b.items[0].sources[0].lecture, "L11");
// Explicit values win, and topics are not duplicated.
assert_eq!(b.items[1].author.as_deref(), Some("Someone Else"));
assert_eq!(b.items[1].topics, vec!["kinetics"]);
assert_eq!(b.items[1].sources[0].lecture, "L12");
}
#[test]
fn cross_file_references_are_checked_against_the_course() {
let course: CourseFile = serde_yaml_ng::from_str(
r#"
course: { code: X, title: Y, term: Z }
lectures:
L11: { title: Kinetics }
learning_objectives:
lo-known: { text: Do the thing, level_ceiling: 2 }
"#,
)
.unwrap();
let b = bank(
r#"
- id: q-a-001
status: draft
level: 4
stem: s
learning_objectives: [lo-known, lo-unknown]
sources: [{ lecture: L99 }]
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
"#,
);
let issues = b.validate(Some(&course));
assert!(
issues
.iter()
.any(|i| i.contains("unknown learning objective `lo-unknown`"))
);
assert!(issues.iter().any(|i| i.contains("unknown lecture `L99`")));
assert!(
issues.iter().any(|i| i.contains("exceeds the ceiling")),
"{issues:?}"
);
}
#[test]
fn history_versions_must_increase() {
let b = bank(
r#"
- id: q-a-001
version: 2
status: draft
level: 1
stem: s
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
history:
- { version: 2, date: 2026-01-01, change: second }
- { version: 1, date: 2026-01-02, change: first }
"#,
);
let issues = b.validate(None);
assert!(issues.iter().any(|i| i.contains("does not increase")));
}
#[test]
fn level_counts_exclude_drafts_and_bonuses() {
let b = bank(
r#"
- id: q-a-001
status: approved
level: 1
cognitive_process: recall
stem: s
learning_objectives: [lo]
sources: [{ lecture: L1 }]
design: { expected_difficulty: 0.8 }
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
- id: q-a-002
status: draft
level: 1
stem: s
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
- id: q-a-003
status: approved
level: 5
cognitive_process: generate
bonus: true
stem: s
learning_objectives: [lo]
sources: [{ lecture: L1 }]
design: { expected_difficulty: 0.3 }
options:
- { id: A, text: a, correct: true }
- { id: B, text: b }
"#,
);
let counts = b.level_counts();
assert_eq!(counts[&Level::Remember], 1);
assert_eq!(counts[&Level::Create], 0, "bonus items are not scored");
assert_eq!(b.assemblable().len(), 2);
}
}
+686
View File
@@ -0,0 +1,686 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Loading a whole course at once, and reporting on what it contains.
//!
//! A [`Catalog`] is every bank in a course, indexed so that an item can be found
//! by its global id (`bank::item`), and so that questions like "how many Apply
//! level items do I have on lecture 12" have a cheap answer.
//!
//! The global id is the join key for everything downstream: assessment records
//! reference it, response tables carry it, and usage history is keyed by it. Bank
//! ids therefore have to be unique across a course, which the catalog enforces
//! at load time rather than letting a silent collision merge two items.
//!
//! The coverage report is the part that changes how you write. It is easy to
//! accumulate forty recall items and believe a topic is covered; a table showing
//! that eleven objectives have no item above level 1 is harder to ignore.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use crate::assessment::AssessmentFile;
use crate::bank::BankFile;
use crate::course::CourseFile;
use crate::error::{Error, Result};
use crate::item::Item;
use crate::layout::Layout;
use crate::taxonomy::{Level, Status};
use crate::yaml;
/// One item plus everything needed to locate it again.
#[derive(Debug, Clone)]
pub struct Entry {
/// The globally unique id, `bank::item`.
pub uid: String,
/// The bank id.
pub bank: String,
/// The file the item came from.
pub path: PathBuf,
/// Position within the bank file, for stable ordering.
pub index: usize,
/// The item itself.
pub item: Item,
}
impl Entry {
/// The item's point value, resolving against the course policy.
///
/// # Arguments
///
/// * `course` - the course whose policy supplies the default.
///
/// # Returns
///
/// The point value.
pub fn points(&self, course: &CourseFile) -> f64 {
self.item.points(course.policy.points_per_item)
}
}
/// Every bank in a course, indexed.
#[derive(Debug, Clone)]
pub struct Catalog {
/// The course registry.
pub course: CourseFile,
/// The resolved directory layout.
pub layout: Layout,
/// Every item, in stable order: by bank id, then by position in the file.
pub entries: Vec<Entry>,
/// Bank metadata by bank id.
pub banks: BTreeMap<String, crate::bank::BankMeta>,
/// Map from global id to index into `entries`.
index: BTreeMap<String, usize>,
}
impl Catalog {
/// Loads a whole course from its directory.
///
/// # Arguments
///
/// * `root` - the course directory containing `course.yaml` and `banks/`.
///
/// # Returns
///
/// The catalog.
///
/// # Errors
///
/// Returns a load error for the course file or any bank, and
/// [`Error::Invalid`] when two banks share an id or two items share a global
/// id, since either makes the join key ambiguous.
pub fn load(root: &Path) -> Result<Catalog> {
let layout = Layout::new(root);
let course = CourseFile::load(&layout.course_file())?;
let mut catalog = Catalog {
course,
layout,
entries: Vec::new(),
banks: BTreeMap::new(),
index: BTreeMap::new(),
};
let mut problems = Vec::new();
let mut files = yaml::list_yaml(&catalog.layout.banks())?;
files.sort();
for path in files {
let bank = BankFile::load_resolved(&path)?;
let bank_id = bank.bank.id.clone();
if let Some(existing) = catalog.banks.get(&bank_id) {
problems.push(format!(
"bank id `{bank_id}` is used by two files (`{}` and `{}`)",
existing.title, bank.bank.title
));
continue;
}
catalog.banks.insert(bank_id.clone(), bank.bank.clone());
for (i, item) in bank.items.into_iter().enumerate() {
let uid = format!("{bank_id}::{}", item.id);
if catalog.index.contains_key(&uid) {
problems.push(format!("duplicate global item id `{uid}`"));
continue;
}
catalog.index.insert(uid.clone(), catalog.entries.len());
catalog.entries.push(Entry {
uid,
bank: bank_id.clone(),
path: path.clone(),
index: i,
item,
});
}
}
if !problems.is_empty() {
return Err(Error::Invalid(problems));
}
Ok(catalog)
}
/// Looks up an item by global id.
///
/// # Arguments
///
/// * `uid` - the global id, `bank::item`.
///
/// # Returns
///
/// The entry, or `None`.
pub fn get(&self, uid: &str) -> Option<&Entry> {
self.index.get(uid).map(|i| &self.entries[*i])
}
/// Looks up an item by global id, erroring when absent.
///
/// # Arguments
///
/// * `uid` - the global id.
///
/// # Returns
///
/// The entry.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when no such item exists.
pub fn require(&self, uid: &str) -> Result<&Entry> {
self.get(uid).ok_or_else(|| Error::Unresolved {
kind: "item",
id: uid.to_string(),
context: None,
})
}
/// Resolves a possibly-unqualified id to a global id.
///
/// Typing `q-mm-kinetics-001` on the command line should work when that id is
/// unambiguous across the course, because remembering which bank a question
/// lives in is exactly the sort of bookkeeping this tool exists to remove.
///
/// # Arguments
///
/// * `id` - a global id, or a bare item id.
///
/// # Returns
///
/// The global id.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when nothing matches, or [`Error::Usage`]
/// when a bare id matches items in more than one bank.
pub fn resolve(&self, id: &str) -> Result<String> {
if self.index.contains_key(id) {
return Ok(id.to_string());
}
let matches: Vec<&Entry> = self.entries.iter().filter(|e| e.item.id == id).collect();
match matches.len() {
0 => Err(Error::Unresolved {
kind: "item",
id: id.to_string(),
context: None,
}),
1 => Ok(matches[0].uid.clone()),
_ => Err(Error::usage(format!(
"`{id}` is ambiguous; it exists in {}. Use the full `bank::item` form.",
matches
.iter()
.map(|e| e.bank.as_str())
.collect::<Vec<_>>()
.join(", ")
))),
}
}
/// Every item that may be placed on a graded assessment.
///
/// # Returns
///
/// References to approved, unretired entries.
pub fn assemblable(&self) -> Vec<&Entry> {
self.entries
.iter()
.filter(|e| e.item.is_assemblable())
.collect()
}
/// Validates every bank against the course registry.
///
/// # Returns
///
/// Problems, prefixed with the file they came from.
pub fn validate(&self) -> Result<Vec<String>> {
let mut issues: Vec<String> = self
.course
.validate()
.into_iter()
.map(|m| format!("course.yaml: {m}"))
.collect();
for path in yaml::list_yaml(&self.layout.banks())? {
let bank = BankFile::load_resolved(&path)?;
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string();
issues.extend(
bank.validate(Some(&self.course))
.into_iter()
.map(|m| format!("{name}: {m}")),
);
}
Ok(issues)
}
/// Validates a record's internal invariants, then its references against this
/// catalog: unknown items, keys that drifted, and fingerprints showing the
/// item was reworded since it was administered.
pub fn validate_record(&self, record: &AssessmentFile) -> Vec<String> {
let mut issues = record.validate();
for p in &record.items {
match self.get(&p.item) {
None => issues.push(format!("question {}: unknown item `{}`", p.number, p.item)),
Some(entry) => {
if let Some(fp) = &p.fingerprint {
if *fp != entry.item.fingerprint() {
issues.push(format!(
"question {} ({}): the item has been edited since this assessment; \
statistics from this administration describe the older wording",
p.number, p.item
));
}
}
if !p.key.is_empty() && p.key != entry.item.key_letters() {
issues.push(format!(
"question {} ({}): the recorded key {:?} differs from the item's current key {:?}",
p.number, p.item, p.key, entry.item.key_letters()));
}
}
}
}
issues
}
/// Estimated working time for a record, in minutes.
pub fn estimated_minutes(&self, record: &AssessmentFile) -> f64 {
let seconds: f64 = record
.items
.iter()
.filter_map(|p| self.get(&p.item))
.map(|e| e.item.expected_seconds())
.sum();
seconds / 60.0
}
/// Counts of assemblable, non-bonus items by level.
///
/// # Returns
///
/// A map from level to count, with every level present.
pub fn level_counts(&self) -> BTreeMap<Level, usize> {
let mut out: BTreeMap<Level, usize> = Level::ALL.iter().map(|l| (*l, 0)).collect();
for e in self.assemblable().iter().filter(|e| !e.item.bonus) {
*out.entry(e.item.level).or_insert(0) += 1;
}
out
}
/// Counts of items by workflow status, over the whole course.
///
/// # Returns
///
/// A map from status to count.
pub fn status_counts(&self) -> BTreeMap<Status, usize> {
let mut out: BTreeMap<Status, usize> = BTreeMap::new();
for e in &self.entries {
*out.entry(e.item.status).or_insert(0) += 1;
}
out
}
/// Items that measure a given objective.
///
/// # Arguments
///
/// * `objective` - the objective id.
///
/// # Returns
///
/// Matching entries.
pub fn by_objective(&self, objective: &str) -> Vec<&Entry> {
self.entries
.iter()
.filter(|e| e.item.learning_objectives.iter().any(|o| o == objective))
.collect()
}
/// Items sourced to a given lecture.
///
/// # Arguments
///
/// * `lecture` - the lecture id.
///
/// # Returns
///
/// Matching entries.
pub fn by_lecture(&self, lecture: &str) -> Vec<&Entry> {
self.entries
.iter()
.filter(|e| e.item.sources.iter().any(|s| s.lecture == lecture))
.collect()
}
/// Items carrying a given topic tag.
///
/// # Arguments
///
/// * `topic` - the topic tag.
///
/// # Returns
///
/// Matching entries.
pub fn by_topic(&self, topic: &str) -> Vec<&Entry> {
self.entries
.iter()
.filter(|e| e.item.topics.iter().any(|t| t == topic))
.collect()
}
/// Every topic tag in use, with counts.
///
/// # Returns
///
/// A map from topic to item count.
pub fn topics(&self) -> BTreeMap<String, usize> {
let mut out: BTreeMap<String, usize> = BTreeMap::new();
for e in &self.entries {
for t in &e.item.topics {
*out.entry(t.clone()).or_insert(0) += 1;
}
}
out
}
/// Builds the coverage report.
///
/// # Returns
///
/// One row per assessed objective plus a list of course-wide gaps.
pub fn coverage(&self) -> Coverage {
let mut rows = Vec::new();
for id in self.course.objectives_in_order() {
let obj = &self.course.learning_objectives[&id];
if !obj.assessed {
continue;
}
let items = self.by_objective(&id);
let usable: Vec<&&Entry> = items.iter().filter(|e| e.item.is_assemblable()).collect();
let mut levels: BTreeSet<Level> = BTreeSet::new();
for e in &usable {
levels.insert(e.item.level);
}
rows.push(CoverageRow {
objective: id.clone(),
text: obj.text.clone(),
unit: obj.unit.clone(),
total: items.len(),
assemblable: usable.len(),
max_level: levels.iter().next_back().copied(),
levels: levels.into_iter().collect(),
ceiling: obj.level_ceiling,
});
}
let mut gaps = Vec::new();
for row in &rows {
if row.total == 0 {
gaps.push(Gap::Uncovered(row.objective.clone()));
} else if row.assemblable == 0 {
gaps.push(Gap::NoApprovedItems(row.objective.clone()));
} else if row.assemblable == 1 {
gaps.push(Gap::SingleItem(row.objective.clone()));
}
// An objective assessed only at the recall level is the most common
// and most consequential blind spot: it looks covered in a count and
// is not covered in fact.
if row.assemblable > 0 && row.max_level == Some(Level::Remember) {
if let Some(ceiling) = row.ceiling {
if ceiling > Level::Remember {
gaps.push(Gap::RecallOnly(row.objective.clone()));
}
} else {
gaps.push(Gap::RecallOnly(row.objective.clone()));
}
}
}
for lec_id in self.course.lectures.keys() {
if self
.by_lecture(lec_id)
.iter()
.filter(|e| e.item.is_assemblable())
.count()
== 0
{
gaps.push(Gap::LectureUnassessed(lec_id.clone()));
}
}
// Items with no objective at all cannot appear in any student report.
for e in &self.entries {
if e.item.learning_objectives.is_empty() && e.item.is_assemblable() {
gaps.push(Gap::ItemWithoutObjective(e.uid.clone()));
}
}
Coverage { rows, gaps }
}
}
/// The coverage report.
#[derive(Debug, Clone)]
pub struct Coverage {
/// One row per assessed objective.
pub rows: Vec<CoverageRow>,
/// Course-wide gaps worth acting on.
pub gaps: Vec<Gap>,
}
/// Coverage of one objective.
#[derive(Debug, Clone)]
pub struct CoverageRow {
/// The objective id.
pub objective: String,
/// The objective text.
pub text: String,
/// The unit it belongs to.
pub unit: Option<String>,
/// Items referencing it, at any status.
pub total: usize,
/// Items that could actually be used.
pub assemblable: usize,
/// The highest level assessed.
pub max_level: Option<Level>,
/// Every level assessed.
pub levels: Vec<Level>,
/// The declared ceiling, when set.
pub ceiling: Option<Level>,
}
/// A specific, actionable hole in the item pool.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Gap {
/// An assessed objective with no items at all.
Uncovered(String),
/// An objective whose items are all drafts or retired.
NoApprovedItems(String),
/// An objective resting on a single item, so one bad item hides it entirely.
SingleItem(String),
/// An objective assessed only at the recall level despite a higher ceiling.
RecallOnly(String),
/// A lecture with no usable items.
LectureUnassessed(String),
/// A usable item that references no objective, so it can never appear in a
/// student report.
ItemWithoutObjective(String),
}
impl Gap {
/// A one-line description.
pub fn message(&self) -> String {
match self {
Gap::Uncovered(id) => format!("objective `{id}` has no items"),
Gap::NoApprovedItems(id) => {
format!("objective `{id}` has items but none are approved")
}
Gap::SingleItem(id) => {
format!("objective `{id}` rests on a single item; one bad item hides it")
}
Gap::RecallOnly(id) => {
format!("objective `{id}` is assessed only at level 1")
}
Gap::LectureUnassessed(id) => format!("lecture `{id}` has no usable items"),
Gap::ItemWithoutObjective(uid) => {
format!("item `{uid}` has no learning objective, so it cannot appear in a report")
}
}
}
/// How much attention the gap deserves.
pub fn severity(&self) -> Severity {
match self {
Gap::Uncovered(_) | Gap::NoApprovedItems(_) => Severity::High,
Gap::RecallOnly(_) | Gap::ItemWithoutObjective(_) => Severity::Medium,
Gap::SingleItem(_) | Gap::LectureUnassessed(_) => Severity::Low,
}
}
}
/// How much attention a finding deserves.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
/// Worth knowing.
Low,
/// Worth scheduling.
Medium,
/// Worth fixing before the next assessment.
High,
}
impl Severity {
/// A short label for report output.
pub fn label(self) -> &'static str {
match self {
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write_course(dir: &Path, extra_lo: &str) {
std::fs::create_dir_all(dir.join("banks")).unwrap();
std::fs::write(
dir.join("course.yaml"),
format!(
r#"
course: {{ code: TEST 101, title: Testing, term: Fall 2026 }}
lectures:
L01: {{ title: One }}
L02: {{ title: Two }}
learning_objectives:
lo-covered: {{ text: Covered objective, lectures: [L01] }}
lo-bare: {{ text: Uncovered objective, lectures: [L02] }}
{extra_lo}
"#
),
)
.unwrap();
}
fn write_bank(dir: &Path, name: &str, body: &str) {
std::fs::write(dir.join("banks").join(name), body).unwrap();
}
fn tmp(tag: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("coursebank-test-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
const APPROVED: &str = r#"
bank:
id: b1
title: First bank
items:
- id: q-x-001
status: approved
level: 1
cognitive_process: recall
stem: What is x?
learning_objectives: [lo-covered]
sources: [{ lecture: L01 }]
design: { expected_difficulty: 0.8 }
options:
- { id: A, text: right, correct: true }
- { id: B, text: wrong }
"#;
#[test]
fn loads_and_indexes_items() {
let dir = tmp("load");
write_course(&dir, "");
write_bank(&dir, "b1.yaml", APPROVED);
let cat = Catalog::load(&dir).expect("catalog loads");
assert_eq!(cat.entries.len(), 1);
assert_eq!(cat.entries[0].uid, "b1::q-x-001");
assert!(cat.get("b1::q-x-001").is_some());
assert_eq!(cat.resolve("q-x-001").unwrap(), "b1::q-x-001");
assert!(cat.validate().unwrap().is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn duplicate_bank_ids_are_fatal() {
let dir = tmp("dupbank");
write_course(&dir, "");
write_bank(&dir, "a.yaml", APPROVED);
write_bank(&dir, "b.yaml", APPROVED);
let err = Catalog::load(&dir).expect_err("must reject colliding bank ids");
assert!(format!("{err}").contains("two files"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ambiguous_bare_ids_are_rejected() {
let dir = tmp("ambig");
write_course(&dir, "");
write_bank(&dir, "a.yaml", APPROVED);
write_bank(&dir, "b.yaml", &APPROVED.replace("id: b1", "id: b2"));
let cat = Catalog::load(&dir).expect("distinct banks load");
assert_eq!(cat.entries.len(), 2);
let err = cat.resolve("q-x-001").expect_err("bare id is ambiguous");
assert!(format!("{err}").contains("ambiguous"));
// The fully qualified form still works.
assert_eq!(cat.resolve("b2::q-x-001").unwrap(), "b2::q-x-001");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn coverage_finds_real_gaps() {
let dir = tmp("coverage");
write_course(&dir, "");
write_bank(&dir, "b1.yaml", APPROVED);
let cat = Catalog::load(&dir).unwrap();
let cov = cat.coverage();
assert_eq!(cov.rows.len(), 2);
assert!(cov.gaps.contains(&Gap::Uncovered("lo-bare".into())));
assert!(cov.gaps.contains(&Gap::SingleItem("lo-covered".into())));
assert!(cov.gaps.contains(&Gap::RecallOnly("lo-covered".into())));
assert!(cov.gaps.contains(&Gap::LectureUnassessed("L02".into())));
assert_eq!(Gap::Uncovered("x".into()).severity(), Severity::High);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn recall_only_respects_a_recall_ceiling() {
let dir = tmp("ceiling");
// An objective you only ever intend to test at level 1 is not a gap.
write_course(&dir, " lo-ceil: { text: Just recall, level_ceiling: 1 }");
write_bank(&dir, "b1.yaml", &APPROVED.replace("lo-covered", "lo-ceil"));
let cat = Catalog::load(&dir).unwrap();
let cov = cat.coverage();
assert!(!cov.gaps.contains(&Gap::RecallOnly("lo-ceil".into())));
let _ = std::fs::remove_dir_all(&dir);
}
}
+709
View File
@@ -0,0 +1,709 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! The course file: identity plus the registries every bank references.
//!
//! Learning objectives and lectures are declared once, in `course.yaml`, and
//! referenced by id from items. That is the single most load-bearing decision in
//! the schema. It means an objective's wording lives in exactly one place, so
//! rewording it updates every report; it means a report can name what a student
//! missed by objective rather than by question number; and it means a dangling
//! reference is a hard error instead of a silently misspelled string that splits
//! your coverage table into two near-identical rows.
//!
//! The course file also declares the term. Items live across terms, so the term
//! belongs to the course and the administration, never to the item.
use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::date::Date;
use crate::error::{Error, Result};
use crate::taxonomy::Level;
use crate::yaml;
/// The schema version this build of the tool writes.
pub const SCHEMA_VERSION: &str = "1.0";
/// The canonical file name inside a course directory.
pub const COURSE_FILE: &str = "course.yaml";
/// A whole course file.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CourseFile {
/// Schema version this file targets.
#[serde(
default = "default_version",
deserialize_with = "yaml::flexible_string"
)]
pub schema_version: String,
/// Course identity.
pub course: Course,
/// Grading and assembly conventions that apply course-wide.
#[serde(default)]
pub policy: Policy,
/// Units or modules, ordered as taught.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub units: Vec<Unit>,
/// Lectures or sessions, keyed by id such as `L11`.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub lectures: BTreeMap<String, Lecture>,
/// Learning objectives, keyed by id such as `lo-mm-kinetics`.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub learning_objectives: BTreeMap<String, Objective>,
/// Shared stimuli for case-based testlets, keyed by id.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub stimuli: BTreeMap<String, Stimulus>,
}
/// Course identity.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Course {
/// Catalog code, e.g. `BIOSC 1540`.
pub code: String,
/// Full title.
pub title: String,
/// The term this instance runs in, e.g. `Spring 2026`.
pub term: String,
/// Granting institution.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub institution: Option<String>,
/// Instructors of record.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub instructors: Vec<String>,
/// A short slug used in generated file names; derived from `code` if absent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slug: Option<String>,
}
impl Course {
/// A filesystem-safe short name for this course.
///
/// # Returns
///
/// The declared slug, or one derived from the course code.
pub fn slug(&self) -> String {
match &self.slug {
Some(s) => s.clone(),
None => slugify(&self.code),
}
}
}
/// Course-wide conventions, so they are stated once rather than per assessment.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Policy {
/// Default points per scored item.
#[serde(default = "one")]
pub points_per_item: f64,
/// Default number of options an item should offer.
#[serde(default = "four")]
pub options_per_item: usize,
/// Levels that may carry bonus rather than scored items.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bonus_levels: Vec<Level>,
/// Whether partial credit for defensible distractors is permitted at all.
#[serde(default = "yes")]
pub allow_partial_credit: bool,
/// The lowest level at which a defensible wrong answer is considered
/// plausible. Awarding credit below this is flagged, because at the recall
/// level a "reasonable wrong answer" usually means the item is unclear.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub partial_credit_floor_level: Option<Level>,
/// Mastery threshold for objective-level reporting, as a proportion.
#[serde(default = "mastery_default")]
pub mastery_threshold: f64,
/// The fewest items on an objective before a report will call it mastered.
#[serde(default = "two_usize")]
pub min_items_for_mastery: usize,
}
impl Default for Policy {
fn default() -> Policy {
Policy {
points_per_item: 1.0,
options_per_item: 4,
bonus_levels: Vec::new(),
allow_partial_credit: true,
partial_credit_floor_level: None,
mastery_threshold: mastery_default(),
min_items_for_mastery: 2,
}
}
}
/// A unit or module of the course.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Unit {
/// Stable id, referenced by lectures and objectives.
pub id: String,
/// Human title.
pub title: String,
/// Optional longer description.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
/// A lecture or class session.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Lecture {
/// Human title.
pub title: String,
/// The date it ran, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub date: Option<Date>,
/// The unit it belongs to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
/// 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.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub readings: Vec<String>,
}
/// A learning objective.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Objective {
/// The objective as you would state it to students. Reports quote this
/// verbatim, so write it in the second person and start with a verb.
pub text: String,
/// The unit it belongs to.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
/// The lectures that develop it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lectures: Vec<String>,
/// 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.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level_ceiling: Option<Level>,
/// Objectives that must be secure before this one is reachable. Student
/// reports walk this backwards to suggest where to start reviewing.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prerequisites: Vec<String>,
/// Free-form tags.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
/// Whether this objective is assessed at all, or is aspirational.
#[serde(default = "yes")]
pub assessed: bool,
}
/// A shared stem or vignette used by several items.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Stimulus {
/// The vignette body, in the markup described in `docs/AUTHORING.md`.
pub body: String,
/// An image or data file to reproduce alongside it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset: Option<String>,
/// A caption for the asset.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
}
impl CourseFile {
/// Loads a course file from disk without validating cross-references.
///
/// # Arguments
///
/// * `path` - path to `course.yaml`.
///
/// # Returns
///
/// The parsed course file.
///
/// # Errors
///
/// Returns [`Error::Io`] if unreadable and [`Error::Yaml`] if it does not
/// match the schema. Unknown keys are errors, so a misspelled field is
/// caught rather than ignored.
pub fn load(path: &Path) -> Result<CourseFile> {
yaml::read(path)
}
/// Finds and loads the course file for a course directory.
///
/// # Arguments
///
/// * `dir` - the course directory.
///
/// # Returns
///
/// The parsed course file.
///
/// # Errors
///
/// Propagates load errors, including absence of `course.yaml`.
pub fn load_dir(dir: &Path) -> Result<CourseFile> {
CourseFile::load(&dir.join(COURSE_FILE))
}
/// Writes the course file back out as YAML.
///
/// # Arguments
///
/// * `path` - destination path.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure.
pub fn save(&self, path: &Path) -> Result<()> {
yaml::write(path, self)
}
/// Checks internal consistency of the registries.
///
/// # Returns
///
/// Every problem found, empty when the file is sound.
pub fn validate(&self) -> Vec<String> {
let mut issues = Vec::new();
if self.course.code.trim().is_empty() {
issues.push("course.code is empty".into());
}
if self.course.title.trim().is_empty() {
issues.push("course.title is empty".into());
}
if self.course.term.trim().is_empty() {
issues.push("course.term is empty".into());
}
if !(0.0..=1.0).contains(&self.policy.mastery_threshold) {
issues.push(format!(
"policy.mastery_threshold must be between 0 and 1, got {}",
self.policy.mastery_threshold
));
}
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 {
*unit_counts.entry(u.id.as_str()).or_insert(0) += 1;
}
for (id, n) in &unit_counts {
if *n > 1 {
issues.push(format!("units: duplicate id `{id}` declared {n} times"));
}
}
for (id, lec) in &self.lectures {
if lec.title.trim().is_empty() {
issues.push(format!("lecture `{id}`: empty title"));
}
if let Some(u) = &lec.unit {
if !unit_ids.contains(&u) {
issues.push(format!("lecture `{id}`: unknown unit `{u}`"));
}
}
}
for (id, lo) in &self.learning_objectives {
if lo.text.trim().is_empty() {
issues.push(format!("objective `{id}`: empty text"));
}
if let Some(u) = &lo.unit {
if !unit_ids.contains(&u) {
issues.push(format!("objective `{id}`: unknown unit `{u}`"));
}
}
for lec in &lo.lectures {
if !self.lectures.contains_key(lec) {
issues.push(format!("objective `{id}`: unknown lecture `{lec}`"));
}
}
for pre in &lo.prerequisites {
if !self.learning_objectives.contains_key(pre) {
issues.push(format!(
"objective `{id}`: unknown prerequisite objective `{pre}`"
));
}
if pre == id {
issues.push(format!("objective `{id}`: lists itself as a prerequisite"));
}
}
}
issues.extend(self.prerequisite_cycles());
issues
}
/// Detects cycles in the objective prerequisite graph.
///
/// A cycle would make a study-order suggestion loop forever, so it is worth
/// catching at validation time rather than at report time.
///
/// # Returns
///
/// One message per objective that participates in a cycle.
fn prerequisite_cycles(&self) -> Vec<String> {
// Iterative depth-first search with three colors.
#[derive(PartialEq, Clone, Copy)]
enum Mark {
White,
Gray,
Black,
}
let mut color: BTreeMap<&String, Mark> = self
.learning_objectives
.keys()
.map(|k| (k, Mark::White))
.collect();
let mut issues = Vec::new();
for root in self.learning_objectives.keys() {
if color[root] != Mark::White {
continue;
}
let mut stack: Vec<(&String, usize)> = vec![(root, 0)];
color.insert(root, Mark::Gray);
while let Some((node, idx)) = stack.pop() {
let empty: Vec<String> = Vec::new();
let pres = self
.learning_objectives
.get(node)
.map(|o| &o.prerequisites)
.unwrap_or(&empty);
if idx < pres.len() {
stack.push((node, idx + 1));
if let Some((key, _)) = self.learning_objectives.get_key_value(&pres[idx]) {
match color[key] {
Mark::Gray => issues.push(format!(
"objective `{node}`: prerequisite cycle through `{key}`"
)),
Mark::White => {
color.insert(key, Mark::Gray);
stack.push((key, 0));
}
Mark::Black => {}
}
}
} else {
color.insert(node, Mark::Black);
}
}
}
issues
}
/// Looks up an objective, erroring on a dangling reference.
///
/// # Arguments
///
/// * `id` - the objective id.
/// * `context` - what referenced it, for the error message.
///
/// # Returns
///
/// The objective.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when the id is not registered.
pub fn objective(&self, id: &str, context: &str) -> Result<&Objective> {
self.learning_objectives
.get(id)
.ok_or_else(|| Error::Unresolved {
kind: "learning objective",
id: id.to_string(),
context: Some(context.to_string()),
})
}
/// Looks up a lecture, erroring on a dangling reference.
///
/// # Arguments
///
/// * `id` - the lecture id.
/// * `context` - what referenced it, for the error message.
///
/// # Returns
///
/// The lecture.
///
/// # Errors
///
/// Returns [`Error::Unresolved`] when the id is not registered.
pub fn lecture(&self, id: &str, context: &str) -> Result<&Lecture> {
self.lectures.get(id).ok_or_else(|| Error::Unresolved {
kind: "lecture",
id: id.to_string(),
context: Some(context.to_string()),
})
}
/// The objective text, or the bare id when unregistered.
///
/// Report rendering uses this so a missing objective degrades to a readable
/// label instead of failing a whole report.
///
/// # Arguments
///
/// * `id` - the objective id.
///
/// # Returns
///
/// The display text.
pub fn objective_text(&self, id: &str) -> String {
self.learning_objectives
.get(id)
.map(|o| o.text.clone())
.unwrap_or_else(|| id.to_string())
}
/// Objectives in a stable teaching order: by unit as declared, then by id.
///
/// # Returns
///
/// Objective ids in report order.
pub fn objectives_in_order(&self) -> Vec<String> {
let unit_rank: BTreeMap<&str, usize> = self
.units
.iter()
.enumerate()
.map(|(i, u)| (u.id.as_str(), i))
.collect();
let mut ids: Vec<&String> = self.learning_objectives.keys().collect();
ids.sort_by_key(|id| {
let lo = &self.learning_objectives[*id];
let rank = lo
.unit
.as_deref()
.and_then(|u| unit_rank.get(u).copied())
.unwrap_or(usize::MAX);
(rank, (*id).clone())
});
ids.into_iter().cloned().collect()
}
/// A skeleton course file for `coursebank init`.
///
/// # Arguments
///
/// * `code` - the course code.
/// * `title` - the course title.
/// * `term` - the term.
///
/// # Returns
///
/// A minimal but valid course file.
pub fn skeleton(code: &str, title: &str, term: &str) -> CourseFile {
let mut lectures = BTreeMap::new();
lectures.insert(
"L01".to_string(),
Lecture {
title: "Course introduction".to_string(),
date: None,
unit: Some("u-intro".to_string()),
slides_url: None,
readings: Vec::new(),
},
);
let mut los = BTreeMap::new();
los.insert(
"lo-example".to_string(),
Objective {
text: "Replace this with an objective stated as a student action.".to_string(),
unit: Some("u-intro".to_string()),
lectures: vec!["L01".to_string()],
level_ceiling: Some(Level::Understand),
prerequisites: Vec::new(),
tags: Vec::new(),
assessed: true,
},
);
CourseFile {
schema_version: SCHEMA_VERSION.to_string(),
course: Course {
code: code.to_string(),
title: title.to_string(),
term: term.to_string(),
institution: None,
instructors: Vec::new(),
slug: None,
},
policy: Policy::default(),
units: vec![Unit {
id: "u-intro".to_string(),
title: "Introduction".to_string(),
description: None,
}],
lectures,
learning_objectives: los,
stimuli: BTreeMap::new(),
}
}
}
/// Lowercases and hyphenates a string for use in file names and ids.
///
/// # Arguments
///
/// * `s` - the text to convert.
///
/// # Returns
///
/// A slug of lowercase alphanumerics separated by single hyphens.
pub fn slugify(s: &str) -> String {
let mut out = String::new();
let mut prev_dash = true;
for ch in s.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
out
}
fn default_version() -> String {
SCHEMA_VERSION.to_string()
}
fn one() -> f64 {
1.0
}
fn four() -> usize {
4
}
fn two_usize() -> usize {
2
}
fn yes() -> bool {
true
}
fn mastery_default() -> f64 {
0.75
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(src: &str) -> CourseFile {
serde_yaml_ng::from_str(src).expect("course parses")
}
#[test]
fn minimal_course_parses_with_defaults() {
let c = parse(
r#"
course:
code: BIOSC 1540
title: Computational Biology
term: Spring 2026
"#,
);
assert_eq!(c.schema_version, "1.0");
assert_eq!(c.policy.options_per_item, 4);
assert_eq!(c.course.slug(), "biosc-1540");
assert!(c.validate().is_empty());
}
#[test]
fn unquoted_schema_version_still_parses() {
// YAML reads 1.0 as a float; authors should not have to remember quotes.
let c = parse(
r#"
schema_version: 1.0
course: { code: X, title: Y, term: Z }
"#,
);
assert_eq!(c.schema_version, "1.0");
}
#[test]
fn unknown_keys_are_rejected() {
let e = serde_yaml_ng::from_str::<CourseFile>(
r#"
course: { code: X, title: Y, term: Z }
lecutres: {}
"#,
);
assert!(e.is_err(), "a misspelled top-level key must not be ignored");
}
#[test]
fn dangling_references_are_reported() {
let c = parse(
r#"
course: { code: X, title: Y, term: Z }
units:
- { id: u-a, title: A }
lectures:
L01: { title: One, unit: u-nope }
learning_objectives:
lo-a: { text: Do a thing, unit: u-a, lectures: [L99], prerequisites: [lo-missing] }
"#,
);
let issues = c.validate();
assert!(issues.iter().any(|i| i.contains("unknown unit `u-nope`")));
assert!(issues.iter().any(|i| i.contains("unknown lecture `L99`")));
assert!(issues.iter().any(|i| i.contains("lo-missing")));
}
#[test]
fn prerequisite_cycles_are_detected() {
let c = parse(
r#"
course: { code: X, title: Y, term: Z }
learning_objectives:
lo-a: { text: A, prerequisites: [lo-b] }
lo-b: { text: B, prerequisites: [lo-a] }
"#,
);
let issues = c.validate();
assert!(
issues.iter().any(|i| i.contains("cycle")),
"expected a cycle report, got {issues:?}"
);
}
#[test]
fn objectives_sort_by_declared_unit_order() {
let c = parse(
r#"
course: { code: X, title: Y, term: Z }
units:
- { id: u-second, title: Second }
- { id: u-first, title: First }
learning_objectives:
lo-z: { text: Z, unit: u-second }
lo-a: { text: A, unit: u-first }
"#,
);
// Declared order wins over alphabetical order of unit ids.
assert_eq!(c.objectives_in_order(), vec!["lo-z", "lo-a"]);
}
#[test]
fn slugify_collapses_punctuation() {
assert_eq!(slugify("BIOSC 1540"), "biosc-1540");
assert_eq!(slugify("Exam 4 -- Final!"), "exam-4-final");
assert_eq!(slugify(" "), "");
}
}
+183
View File
@@ -0,0 +1,183 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! The usage history of a course, derived by scanning assessment records.
//!
//! There is deliberately no separate ledger file. A ledger duplicates
//! information the records already hold and then drifts away from it; this
//! derives the same answers from the one artifact that has to be right anyway.
//! An item was used exactly when it appears on a record.
//!
//! This is an aggregate over the records, the same kind of thing as
//! [`crate::catalog::Catalog`], which is why it sits beside the schema rather
//! than inside [`crate::assessment`].
use std::path::Path;
use crate::assessment::{AssessmentFile, Kind};
use crate::date::Date;
use crate::error::Result;
/// One recorded appearance of an item, derived from the assessment records.
#[derive(Debug, Clone)]
pub struct Usage {
/// The item's global id.
pub item: String,
/// The assessment id it appeared on.
pub assessment: String,
/// The assessment title.
pub title: String,
/// The kind of assessment.
pub kind: Kind,
/// When it was administered, when recorded.
pub date: Option<Date>,
/// The printed question number.
pub number: u32,
/// The fingerprint as used.
pub fingerprint: Option<String>,
}
/// The usage history of a course, built by scanning assessment records.
#[derive(Debug, Clone, Default)]
pub struct History {
/// Every recorded appearance, newest last.
pub usages: Vec<Usage>,
}
impl History {
/// Builds the history from a directory of assessment records.
///
/// # Arguments
///
/// * `dir` - the assessments directory.
///
/// # Returns
///
/// The history.
///
/// # Errors
///
/// Propagates load errors.
pub fn load(dir: &Path) -> Result<History> {
let mut usages = Vec::new();
for file in AssessmentFile::load_all(dir)? {
for p in &file.items {
usages.push(Usage {
item: p.item.clone(),
assessment: file.assessment.id.clone(),
title: file.assessment.title.clone(),
kind: file.assessment.kind,
date: file.assessment.date,
number: p.number,
fingerprint: p.fingerprint.clone(),
});
}
}
usages.sort_by(|a, b| {
a.date
.cmp(&b.date)
.then(a.assessment.cmp(&b.assessment))
.then(a.number.cmp(&b.number))
});
Ok(History { usages })
}
/// Every appearance of one item, oldest first.
///
/// # Arguments
///
/// * `uid` - the item's global id.
///
/// # Returns
///
/// Matching usages.
pub fn for_item(&self, uid: &str) -> Vec<&Usage> {
self.usages.iter().filter(|u| u.item == uid).collect()
}
/// The most recent date an item was used.
///
/// # Arguments
///
/// * `uid` - the item's global id.
///
/// # Returns
///
/// The date, or `None` when never used or never dated.
pub fn last_used(&self, uid: &str) -> Option<Date> {
self.for_item(uid).iter().filter_map(|u| u.date).max()
}
/// How many times an item has been used.
///
/// # Arguments
///
/// * `uid` - the item's global id.
///
/// # Returns
///
/// The count.
pub fn use_count(&self, uid: &str) -> usize {
self.for_item(uid).len()
}
/// Whether an item is still inside its reuse cooldown.
///
/// # Arguments
///
/// * `uid` - the item's global id.
/// * `cooldown_days` - the minimum gap between uses.
/// * `as_of` - the date of the assessment being assembled.
///
/// # Returns
///
/// `true` when the item was used too recently to reuse.
pub fn in_cooldown(&self, uid: &str, cooldown_days: i64, as_of: Date) -> bool {
match self.last_used(uid) {
Some(last) => last.days_until(as_of) < cooldown_days,
None => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn history_tracks_last_use_and_cooldown() {
let h = History {
usages: vec![
Usage {
item: "b::q-1".into(),
assessment: "exam-1".into(),
title: "Exam 1".into(),
kind: Kind::Exam,
date: Some("2025-09-15".parse().unwrap()),
number: 4,
fingerprint: None,
},
Usage {
item: "b::q-1".into(),
assessment: "exam-3".into(),
title: "Exam 3".into(),
kind: Kind::Exam,
date: Some("2026-02-10".parse().unwrap()),
number: 7,
fingerprint: None,
},
],
};
assert_eq!(h.use_count("b::q-1"), 2);
assert_eq!(h.use_count("b::q-2"), 0);
assert_eq!(h.last_used("b::q-1").unwrap().to_string(), "2026-02-10");
let exam_date: Date = "2026-04-23".parse().unwrap();
// 72 days elapsed, so a 90-day cooldown still blocks it and a 60-day one
// does not.
assert!(h.in_cooldown("b::q-1", 90, exam_date));
assert!(!h.in_cooldown("b::q-1", 60, exam_date));
assert!(!h.in_cooldown("b::never-used", 3650, exam_date));
}
}
+824
View File
@@ -0,0 +1,824 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! The item: one assessable question, with both the intent it was written with
//! and the evidence it has produced.
//!
//! The organizing idea is that those two things belong in one versioned place.
//! [`Design`] is written before an item is ever used and says what you predict:
//! how hard, how discriminating, how long, and what the item is meant to reveal.
//! [`Calibration`] is written by the tool afterwards and says what happened.
//! Keeping them adjacent is what turns a question bank into an instrument you
//! can improve, because every administration produces a checkable prediction.
//!
//! One deliberate departure from a naive design: [`Calibration`] is *cumulative*
//! rather than per-administration. Raw per-response data belongs in the Parquet
//! tables under `data/`, which are far better at holding it, and an item's YAML
//! holds the rolled-up estimate plus a list of which administrations went into
//! it. That keeps bank files readable and reviewable in a pull request while
//! still letting statistics accumulate across terms.
use serde::{Deserialize, Serialize};
use crate::date::Date;
use crate::hash::fingerprint;
use crate::taxonomy::{
CognitiveProcess, Discrimination, ErrorType, Flag, Format, Level, ReviewAction, Status,
};
/// One assessable question.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Item {
/// Stable id, unique within its bank, conventionally `q-<slug>-NNN`.
///
/// Ids are never reused and never renumbered: the id is the join key that
/// ties an item to every assessment it has appeared on and every response
/// row ever recorded for it.
pub id: String,
/// Revision counter, bumped whenever the content changes in a way that
/// invalidates pooled statistics.
#[serde(default = "one_u32")]
pub version: u32,
/// Workflow state; only [`Status::Approved`] items may be assembled.
pub status: Status,
/// Cognitive demand, 1 through 5.
pub level: Level,
/// The specific process the item elicits. Required for approval, and checked
/// against `level`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cognitive_process: Option<CognitiveProcess>,
/// Response format.
#[serde(default = "default_format")]
pub format: Format,
/// A bonus item, scored outside the graded total.
#[serde(default, skip_serializing_if = "is_false")]
pub bonus: bool,
/// Point value; falls back to the course policy when absent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub points: Option<f64>,
/// A short human title, used in tables and Canvas question names.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Id of a shared stimulus in the course registry, for case-based testlets.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stimulus: Option<String>,
/// The prompt.
pub stem: String,
/// The answer options in canonical order. Shuffling happens at export time
/// per form, never here, so the bank stays diffable.
pub options: Vec<Choice>,
/// Objectives this item measures, as ids into the course registry.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub learning_objectives: Vec<String>,
/// Where the material was taught.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<Source>,
/// Free-form topic tags, for slicing a bank by subject rather than lecture.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub topics: Vec<String>,
/// Item ids or objective ids a student needs before this is fair.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prerequisites: Vec<String>,
/// Figures or data files reproduced with the item.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assets: Vec<Asset>,
/// What you predicted before using it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub design: Option<Design>,
/// What the evidence says, accumulated across administrations.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub calibration: Option<Calibration>,
/// The last review decision recorded for this item.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review: Option<Review>,
/// Append-only change log.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub history: Vec<HistoryEntry>,
/// The author of record.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
/// Notes that must never reach a student.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes_private: Option<String>,
/// Set when the item was retired, with the reason.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retired: Option<Retirement>,
}
/// One answer option.
///
/// The optional fields are what separate a designed distractor from filler. An
/// option that names the [`ErrorType`] it targets and the misconception behind it
/// is one you can report on: when a third of the cohort picks it, you know what
/// they were thinking, and the student report can say so.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Choice {
/// Option letter, `A` through `H`.
pub id: String,
/// The option text.
pub text: String,
/// Whether this option is keyed correct.
#[serde(default)]
pub correct: bool,
/// Credit awarded, from 0 to 1. Absent means 1.0 when correct, else 0.0.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credit: Option<f64>,
/// Instructor-facing rationale for why this option is right or wrong.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub explanation: Option<String>,
/// The nudge you would give a student reconsidering this option.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
/// The specific wrong idea this distractor is built to capture.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub misconception: Option<String>,
/// The category of that error.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_type: Option<ErrorType>,
/// Whether a wrong option is defensible enough to earn partial credit.
#[serde(default, skip_serializing_if = "is_false")]
pub defensible: bool,
/// The argument for why it is defensible. Required whenever credit is
/// awarded to a wrong option, so partial credit is always justified in
/// writing rather than by memory.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub defense: Option<String>,
/// Text released to students after the assessment. This is what a student
/// report shows them when they chose this option.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub feedback_student: Option<String>,
/// Your a priori guess at how often this option is chosen.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selection_rate_expected: Option<f64>,
}
impl Choice {
/// The credit this option earns, resolving the default from `correct`.
///
/// # Returns
///
/// Credit in `[0, 1]`.
pub fn credit(&self) -> f64 {
self.credit.unwrap_or(if self.correct { 1.0 } else { 0.0 })
}
/// Whether this option awards credit without being keyed correct.
pub fn is_partial(&self) -> bool {
!self.correct && self.credit() > 0.0
}
/// The best available student-facing explanation of this option.
///
/// Prefers explicit student feedback, then the misconception, then the
/// instructor explanation, so a report degrades gracefully as authoring
/// completeness varies.
///
/// # Returns
///
/// The text, or `None` when the option carries no rationale at all.
pub fn student_text(&self) -> Option<&str> {
self.feedback_student
.as_deref()
.or(self.misconception.as_deref())
.or(self.explanation.as_deref())
}
}
/// Where the assessed material was taught.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Source {
/// Lecture id in the course registry.
pub lecture: String,
/// Slide numbers, so a student report can point at a page rather than a
/// whole lecture.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub slides: Vec<u32>,
/// Readings, cited however you cite them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub readings: Vec<String>,
/// A timestamp into a recording, in seconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recording_seconds: Option<u32>,
}
/// A figure or data file reproduced with an item.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Asset {
/// Path relative to the course root.
pub path: String,
/// Alt text. Required in practice for accessibility; the linter says so.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alt: Option<String>,
/// A caption printed below the figure.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
}
/// The a priori design of an item: your predictions, written down.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Design {
/// The proportion of the target cohort you expect to answer correctly.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_difficulty: Option<f64>,
/// How sharply you expect it to separate students.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_discrimination: Option<Discrimination>,
/// How long you expect it to take, in seconds. Summed over a form, this is
/// how you check that an exam fits the period.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_time_seconds: Option<f64>,
/// What the item is meant to reveal, and why it sits at its level.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rationale: Option<String>,
}
/// Accumulated evidence about an item's behavior.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Calibration {
/// The administrations pooled into these numbers.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub administrations: Vec<String>,
/// When the calibration was last recomputed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated: Option<Date>,
/// The content fingerprint these statistics describe. If it differs from the
/// item's current fingerprint, the item was edited after calibration and the
/// numbers are stale; the linter says so.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>,
/// Total examinees pooled.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub n_examinees: Option<usize>,
/// Proportion correct.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub p_value: Option<f64>,
/// Corrected item-total point-biserial correlation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub point_biserial: Option<f64>,
/// Upper-minus-lower-group discrimination index.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discrimination_index: Option<f64>,
/// Mean response time, when the platform reports it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mean_response_time_seconds: Option<f64>,
/// Proportion of responses faster than plausible reading time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rapid_guess_rate: Option<f64>,
/// Per-option behavior, keyed by option letter.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub option_stats: std::collections::BTreeMap<String, OptionStat>,
/// Fitted item response theory parameters.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub irt: Option<IrtParams>,
/// Machine-detected problems.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub flags: Vec<Flag>,
}
/// How one option behaved.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OptionStat {
/// Proportion of examinees who chose it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selection_rate: Option<f64>,
/// Correlation between choosing it and total score. Negative for a working
/// distractor; positive on a distractor is a warning.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub point_biserial: Option<f64>,
/// Selection rate among the top scoring group.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upper_group_rate: Option<f64>,
/// Selection rate among the bottom scoring group.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lower_group_rate: Option<f64>,
}
/// Fitted item response theory parameters.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IrtParams {
/// Which model was fitted.
pub model: IrtModel,
/// Discrimination.
pub a: f64,
/// Difficulty, on the same scale as ability.
pub b: f64,
/// Lower asymptote, the guessing parameter.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub c: Option<f64>,
/// Standard error of `a`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub se_a: Option<f64>,
/// Standard error of `b`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub se_b: Option<f64>,
/// Examinees the fit was based on. Small samples give unstable parameters,
/// so this travels with them rather than being looked up later.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub n: Option<usize>,
/// Whether priors were used, which matters when interpreting `a`.
#[serde(default, skip_serializing_if = "is_false")]
pub bayesian: bool,
}
/// The item response theory model family.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IrtModel {
/// One parameter: difficulty only, discrimination fixed at 1.
Rasch,
/// Two parameters: discrimination and difficulty.
#[serde(rename = "2pl")]
TwoPl,
/// Three parameters, adding a lower asymptote for guessing.
#[serde(rename = "3pl")]
ThreePl,
}
impl IrtModel {
/// The token used in YAML.
pub fn as_str(self) -> &'static str {
match self {
IrtModel::Rasch => "rasch",
IrtModel::TwoPl => "2pl",
IrtModel::ThreePl => "3pl",
}
}
}
/// A recorded review decision.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Review {
/// Who reviewed it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reviewed_by: Option<String>,
/// When.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reviewed_on: Option<Date>,
/// What was decided.
pub action: ReviewAction,
/// Why.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
/// Why and when an item left service.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Retirement {
/// When it was retired.
pub on: Date,
/// Why.
pub reason: String,
/// A replacement item id, when one exists.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replaced_by: Option<String>,
}
/// One entry in an item's change log.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HistoryEntry {
/// The version this change produced.
pub version: u32,
/// When it was made.
pub date: Date,
/// Who made it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
/// What changed.
pub change: String,
}
impl Item {
/// Builds a draft item with everything optional left empty.
///
/// A constructor rather than a `Default` implementation, because there is no
/// sensible default id, stem, or option set: an item missing any of those is
/// not a lesser item, it is not an item. Requiring them at construction means
/// the only way to get a half-built `Item` is deliberately.
///
/// The result is `Status::Draft` and deliberately will not pass
/// [`Item::is_assemblable`] — it still needs learning objectives, sources, and
/// a cognitive process before it can be drawn onto an assessment.
///
/// # Arguments
///
/// * `id` - the item id.
/// * `level` - the cognitive level.
/// * `stem` - the question.
/// * `options` - the answer options.
///
/// # Returns
///
/// The draft item.
pub fn draft(id: &str, level: Level, stem: &str, options: Vec<Choice>) -> Item {
Item {
id: id.to_string(),
version: 1,
status: Status::Draft,
level,
cognitive_process: None,
format: if options.iter().filter(|o| o.correct).count() > 1 {
Format::MultipleResponse
} else {
Format::SingleBestAnswer
},
bonus: false,
points: None,
title: None,
stimulus: None,
stem: stem.to_string(),
options,
learning_objectives: Vec::new(),
sources: Vec::new(),
topics: Vec::new(),
prerequisites: Vec::new(),
assets: Vec::new(),
design: None,
calibration: None,
review: None,
history: Vec::new(),
author: None,
notes_private: None,
retired: None,
}
}
/// Indices of the keyed-correct options.
///
/// # Returns
///
/// Zero-based indices into `options`.
pub fn key_indices(&self) -> Vec<usize> {
self.options
.iter()
.enumerate()
.filter(|(_, o)| o.correct)
.map(|(i, _)| i)
.collect()
}
/// The keyed-correct option letters, sorted.
///
/// # Returns
///
/// Letters such as `["C"]` or `["A", "B"]`.
pub fn key_letters(&self) -> Vec<String> {
let mut out: Vec<String> = self
.options
.iter()
.filter(|o| o.correct)
.map(|o| o.id.clone())
.collect();
out.sort();
out
}
/// Looks up an option by letter.
///
/// # Arguments
///
/// * `letter` - the option id, case insensitive.
///
/// # Returns
///
/// The option, or `None`.
pub fn option(&self, letter: &str) -> Option<&Choice> {
self.options
.iter()
.find(|o| o.id.eq_ignore_ascii_case(letter))
}
/// Whether the item keys more than one option.
pub fn is_multi_key(&self) -> bool {
self.key_indices().len() > 1
}
/// The display title, falling back to a truncated stem.
///
/// # Returns
///
/// A short label suitable for a table or a Canvas question name.
pub fn display_title(&self) -> String {
if let Some(t) = &self.title {
if !t.trim().is_empty() {
return t.trim().to_string();
}
}
let flat = self.stem.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() <= 60 {
flat
} else {
let head: String = flat.chars().take(57).collect();
format!("{head}...")
}
}
/// A content fingerprint over everything that affects what a student sees.
///
/// Metadata deliberately does not contribute: retagging an objective must not
/// invalidate pooled statistics, but rewording an option must.
///
/// # Returns
///
/// The fingerprint as hex.
pub fn fingerprint(&self) -> String {
let mut parts: Vec<String> = vec![self.stem.trim().to_string()];
// Canonicalize by option letter so reordering the YAML block, which does
// not change the item, does not change the fingerprint.
let mut opts: Vec<&Choice> = self.options.iter().collect();
opts.sort_by(|a, b| a.id.cmp(&b.id));
for o in opts {
parts.push(format!(
"{}|{}|{}",
o.id,
if o.correct { "1" } else { "0" },
o.text.trim()
));
}
if let Some(s) = &self.stimulus {
parts.push(format!("stimulus:{s}"));
}
fingerprint(parts.iter().map(|s| s.as_str()))
}
/// Whether the recorded calibration matches the current content.
///
/// # Returns
///
/// `false` when the item was edited after it was calibrated.
pub fn calibration_is_current(&self) -> bool {
match self
.calibration
.as_ref()
.and_then(|c| c.fingerprint.as_ref())
{
Some(fp) => *fp == self.fingerprint(),
None => true,
}
}
/// The point value, resolving against a course default.
///
/// # Arguments
///
/// * `default_points` - the course policy value.
///
/// # Returns
///
/// The point value to use.
pub fn points(&self, default_points: f64) -> f64 {
self.points.unwrap_or(default_points)
}
/// Whether this item may be placed on a graded assessment.
pub fn is_assemblable(&self) -> bool {
self.status.is_usable() && self.retired.is_none()
}
/// The expected time in seconds, falling back to a level-based estimate.
///
/// The fallbacks are rough but useful: without them, a form's total time
/// estimate silently drops every item that has no `design` block.
///
/// # Returns
///
/// Seconds.
pub fn expected_seconds(&self) -> f64 {
if let Some(t) = self.design.as_ref().and_then(|d| d.expected_time_seconds) {
return t;
}
match self.level {
Level::Remember => 35.0,
Level::Understand => 65.0,
Level::Apply => 95.0,
Level::Analyze => 130.0,
Level::Create => 165.0,
}
}
/// Appends a change-log entry and bumps the version.
///
/// # Arguments
///
/// * `change` - a description of what changed.
/// * `author` - who made the change.
pub fn record_change(&mut self, change: &str, author: Option<&str>) {
self.version += 1;
self.history.push(HistoryEntry {
version: self.version,
date: Date::today(),
author: author.map(|a| a.to_string()),
change: change.to_string(),
});
}
}
fn one_u32() -> u32 {
1
}
fn default_format() -> Format {
Format::SingleBestAnswer
}
fn is_false(b: &bool) -> bool {
!*b
}
#[cfg(test)]
mod tests {
use super::*;
fn item(src: &str) -> Item {
serde_yaml_ng::from_str(src).expect("item parses")
}
const MINIMAL: &str = r#"
id: q-demo-001
status: draft
level: 2
stem: Which statement best explains the effect?
options:
- { id: A, text: Right, correct: true }
- { id: B, text: Wrong }
- { id: C, text: Also wrong }
"#;
#[test]
fn minimal_item_parses_with_defaults() {
let it = item(MINIMAL);
assert_eq!(it.version, 1);
assert_eq!(it.format, Format::SingleBestAnswer);
assert!(!it.bonus);
assert_eq!(it.key_letters(), vec!["A"]);
assert!(!it.is_multi_key());
assert!(!it.is_assemblable(), "drafts are not assemblable");
}
#[test]
fn credit_defaults_from_correctness() {
let it = item(MINIMAL);
assert_eq!(it.option("A").unwrap().credit(), 1.0);
assert_eq!(it.option("B").unwrap().credit(), 0.0);
assert!(!it.option("B").unwrap().is_partial());
let with_partial = item(
r#"
id: q-demo-002
status: draft
level: 5
stem: s
options:
- { id: A, text: Right, correct: true }
- { id: B, text: Defensible, credit: 0.5, defensible: true, defense: because }
- { id: C, text: Wrong }
"#,
);
assert!(with_partial.option("B").unwrap().is_partial());
assert_eq!(with_partial.option("B").unwrap().credit(), 0.5);
}
#[test]
fn fingerprint_tracks_content_not_metadata() {
let base = item(MINIMAL);
let mut retagged = base.clone();
retagged.topics = vec!["kinetics".into()];
retagged.learning_objectives = vec!["lo-a".into()];
retagged.author = Some("someone".into());
assert_eq!(
base.fingerprint(),
retagged.fingerprint(),
"metadata must not invalidate pooled statistics"
);
let mut reworded = base.clone();
reworded.options[1].text = "Wrong, but differently".into();
assert_ne!(base.fingerprint(), reworded.fingerprint());
let mut rekeyed = base.clone();
rekeyed.options[0].correct = false;
rekeyed.options[1].correct = true;
assert_ne!(base.fingerprint(), rekeyed.fingerprint());
}
#[test]
fn fingerprint_ignores_yaml_option_order() {
let a = item(MINIMAL);
let b = item(
r#"
id: q-demo-001
status: draft
level: 2
stem: Which statement best explains the effect?
options:
- { id: C, text: Also wrong }
- { id: A, text: Right, correct: true }
- { id: B, text: Wrong }
"#,
);
assert_eq!(a.fingerprint(), b.fingerprint());
}
#[test]
fn stale_calibration_is_detectable() {
let mut it = item(MINIMAL);
assert!(it.calibration_is_current(), "no calibration is not stale");
let fp = it.fingerprint();
it.calibration = Some(Calibration {
fingerprint: Some(fp),
..Calibration::default()
});
assert!(it.calibration_is_current());
it.stem = "A different question entirely?".into();
assert!(!it.calibration_is_current());
}
#[test]
fn display_title_truncates_long_stems() {
let mut it = item(MINIMAL);
it.title = None;
it.stem = "word ".repeat(40);
let t = it.display_title();
assert!(t.ends_with("..."));
assert_eq!(t.chars().count(), 60);
}
#[test]
fn unknown_item_keys_are_rejected() {
let bad = serde_yaml_ng::from_str::<Item>(
r#"
id: q-demo-001
status: draft
level: 2
stem: s
steam: oops
options:
- { id: A, text: a, correct: true }
"#,
);
assert!(bad.is_err());
}
#[test]
fn record_change_bumps_version_and_logs() {
let mut it = item(MINIMAL);
it.record_change("clarified the stem", Some("Alex"));
assert_eq!(it.version, 2);
assert_eq!(it.history.len(), 1);
assert_eq!(it.history[0].version, 2);
}
#[test]
fn irt_model_tokens_round_trip() {
assert_eq!(serde_json::to_string(&IrtModel::TwoPl).unwrap(), "\"2pl\"");
assert_eq!(
serde_json::from_str::<IrtModel>("\"3pl\"").unwrap(),
IrtModel::ThreePl
);
}
}
+98
View File
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! The on-disk layout of a course directory.
use std::path::PathBuf;
use crate::course::COURSE_FILE;
use crate::error::{Error, Result};
/// The standard directory layout of a course, resolved from a root.
#[derive(Debug, Clone)]
pub struct Layout {
/// The course root.
pub root: PathBuf,
}
impl Layout {
/// Builds a layout from a course root directory.
///
/// # Arguments
///
/// * `root` - the course directory.
///
/// # Returns
///
/// The layout.
pub fn new(root: impl Into<PathBuf>) -> Layout {
Layout { root: root.into() }
}
/// Path to `course.yaml`.
pub fn course_file(&self) -> PathBuf {
self.root.join(COURSE_FILE)
}
/// Directory holding item bank YAML files.
pub fn banks(&self) -> PathBuf {
self.root.join("banks")
}
/// Directory holding assessment records.
pub fn assessments(&self) -> PathBuf {
self.root.join("assessments")
}
/// Directory holding response tables and derived statistics.
pub fn data(&self) -> PathBuf {
self.root.join("data")
}
/// Directory holding generated reports.
pub fn reports(&self) -> PathBuf {
self.root.join("reports")
}
/// Directory holding generated exports such as QTI packages.
pub fn build(&self) -> PathBuf {
self.root.join("build")
}
/// Directory holding emitted JSON Schema files for editor validation.
pub fn schema(&self) -> PathBuf {
self.root.join("schema")
}
/// Directory holding Typst export templates and their configuration.
///
/// Unlike the other directories, this one is *not* created by
/// [`Layout::create_all`]. Its absence is meaningful: a course with no
/// `templates/` directory uses the templates compiled into the binary, and
/// creating an empty one on `init` would suggest a customization step is
/// required when it is not. `coursebank template dump` creates it on demand.
pub fn templates(&self) -> PathBuf {
self.root.join("templates")
}
/// Creates every directory in the layout.
///
/// # Errors
///
/// Returns [`Error::Io`] if a directory cannot be created.
pub fn create_all(&self) -> Result<()> {
for dir in [
self.root.clone(),
self.banks(),
self.assessments(),
self.data(),
self.reports(),
self.build(),
self.schema(),
] {
std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
}
Ok(())
}
}
+636
View File
@@ -0,0 +1,636 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! The pedagogical vocabulary: levels, cognitive processes, error types, and
//! workflow states.
use std::fmt;
use serde::{Deserialize, Serialize};
/// Cognitive demand, following the revised Bloom taxonomy.
///
/// Serialized as the integers 1 through 5 so YAML reads `level: 3`. The derived
/// [`Ord`] follows declaration order, which is ascending demand.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "u8", into = "u8")]
pub enum Level {
/// Retrieve a fact or definition.
Remember,
/// Construct meaning; explain, compare, classify.
Understand,
/// Carry out a procedure in a given situation.
Apply,
/// Break material apart and relate the pieces.
Analyze,
/// Judge against criteria, or assemble something new.
Create,
}
impl Level {
/// Every level in ascending order, for iteration in reports.
pub const ALL: [Level; 5] = [
Level::Remember,
Level::Understand,
Level::Apply,
Level::Analyze,
Level::Create,
];
/// The numeric code used in YAML and on printed badges.
pub fn code(self) -> u8 {
match self {
Level::Remember => 1,
Level::Understand => 2,
Level::Apply => 3,
Level::Analyze => 4,
Level::Create => 5,
}
}
/// The level for a numeric code, if it is one.
///
/// Flat storage writes `0` for an unknown level rather than an empty cell,
/// because a numeric column with holes in it is awkward in every columnar
/// format. This turns that convention back into an `Option`.
///
/// # Arguments
///
/// * `code` - the numeric code, where anything outside 1..=5 means unknown.
///
/// # Returns
///
/// The level, or `None`.
pub fn from_code(code: u8) -> Option<Level> {
match code {
1 => Some(Level::Remember),
2 => Some(Level::Understand),
3 => Some(Level::Apply),
4 => Some(Level::Analyze),
5 => Some(Level::Create),
_ => None,
}
}
/// The category name, e.g. `"Apply"`.
pub fn name(self) -> &'static str {
match self {
Level::Remember => "Remember",
Level::Understand => "Understand",
Level::Apply => "Apply",
Level::Analyze => "Analyze",
Level::Create => "Evaluate/Create",
}
}
/// A one-line description suitable for a student-facing report.
pub fn blurb(self) -> &'static str {
match self {
Level::Remember => "recalling terms, facts, and definitions",
Level::Understand => "explaining ideas in your own words",
Level::Apply => "using a procedure in a new situation",
Level::Analyze => "taking a situation apart and relating the pieces",
Level::Create => "judging alternatives or building something new",
}
}
/// The cognitive processes that belong to this level.
pub fn processes(self) -> &'static [CognitiveProcess] {
use CognitiveProcess as P;
match self {
Level::Remember => &[P::Recognize, P::Recall],
Level::Understand => &[
P::Interpret,
P::Exemplify,
P::Classify,
P::Summarize,
P::Infer,
P::Compare,
P::Explain,
],
Level::Apply => &[P::Execute, P::Implement],
Level::Analyze => &[P::Differentiate, P::Organize, P::Attribute],
Level::Create => &[P::Check, P::Critique, P::Generate, P::Plan, P::Produce],
}
}
/// Whether a process is consistent with this level.
///
/// # Arguments
///
/// * `process` - the process to check.
///
/// # Returns
///
/// `true` when the pairing is coherent.
pub fn allows(self, process: CognitiveProcess) -> bool {
self.processes().contains(&process)
}
}
impl TryFrom<u8> for Level {
type Error = String;
fn try_from(v: u8) -> Result<Level, String> {
match v {
1 => Ok(Level::Remember),
2 => Ok(Level::Understand),
3 => Ok(Level::Apply),
4 => Ok(Level::Analyze),
5 => Ok(Level::Create),
other => Err(format!("level must be 1 through 5, got {other}")),
}
}
}
impl From<Level> for u8 {
fn from(l: Level) -> u8 {
l.code()
}
}
impl fmt::Display for Level {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "L{}", self.code())
}
}
/// The specific cognitive process an item is designed to elicit.
///
/// Naming the process, not just the level, is what makes the level claim
/// checkable: it forces you to say which of the several things "Understand"
/// could mean you actually wrote.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CognitiveProcess {
/// Identify a previously encountered item.
Recognize,
/// Retrieve from long-term memory unaided.
Recall,
/// Restate in another representation.
Interpret,
/// Give an instance of a category.
Exemplify,
/// Assign an instance to a category.
Classify,
/// Abstract a general theme.
Summarize,
/// Draw a logical conclusion from given information.
Infer,
/// Detect correspondences between two things.
Compare,
/// Construct a cause-and-effect account.
Explain,
/// Apply a procedure to a familiar task.
Execute,
/// Apply a procedure to an unfamiliar task.
Implement,
/// Distinguish relevant from irrelevant parts.
Differentiate,
/// Determine how elements fit a structure.
Organize,
/// Determine a point of view or intent.
Attribute,
/// Test for internal consistency.
Check,
/// Judge against external criteria.
Critique,
/// Propose alternative hypotheses.
Generate,
/// Devise a procedure.
Plan,
/// Construct a product.
Produce,
}
impl CognitiveProcess {
/// The level this process belongs to.
pub fn level(self) -> Level {
for level in Level::ALL {
if level.allows(self) {
return level;
}
}
// Unreachable: every variant appears in exactly one level's list.
Level::Remember
}
/// Every process, in level order.
pub const ALL: [CognitiveProcess; 19] = [
CognitiveProcess::Recognize,
CognitiveProcess::Recall,
CognitiveProcess::Interpret,
CognitiveProcess::Exemplify,
CognitiveProcess::Classify,
CognitiveProcess::Summarize,
CognitiveProcess::Infer,
CognitiveProcess::Compare,
CognitiveProcess::Explain,
CognitiveProcess::Execute,
CognitiveProcess::Implement,
CognitiveProcess::Differentiate,
CognitiveProcess::Organize,
CognitiveProcess::Attribute,
CognitiveProcess::Check,
CognitiveProcess::Critique,
CognitiveProcess::Generate,
CognitiveProcess::Plan,
CognitiveProcess::Produce,
];
/// The snake_case token used in YAML.
pub fn as_str(self) -> &'static str {
use CognitiveProcess as P;
match self {
P::Recognize => "recognize",
P::Recall => "recall",
P::Interpret => "interpret",
P::Exemplify => "exemplify",
P::Classify => "classify",
P::Summarize => "summarize",
P::Infer => "infer",
P::Compare => "compare",
P::Explain => "explain",
P::Execute => "execute",
P::Implement => "implement",
P::Differentiate => "differentiate",
P::Organize => "organize",
P::Attribute => "attribute",
P::Check => "check",
P::Critique => "critique",
P::Generate => "generate",
P::Plan => "plan",
P::Produce => "produce",
}
}
}
impl fmt::Display for CognitiveProcess {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// The category of mistake a distractor is built to capture.
///
/// This does double duty. It disciplines authoring, because a distractor you
/// cannot name an error for is probably filler. And it makes item analysis
/// legible afterwards: a high selection rate on a `DroppedStep` option tells you
/// where in a procedure students slip, which a bare letter never would.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorType {
/// Confused one remembered fact for a neighboring one.
RecallConfusion,
/// Swapped two terms that sound or look alike.
TerminologySwap,
/// Holds a specific, nameable wrong model.
Misconception,
/// Knows part of the idea but not all of it.
IncompleteUnderstanding,
/// Applied a valid rule outside its scope.
Overgeneralization,
/// True but irrelevant to the question asked.
PlausibleIrrelevant,
/// Omitted a step in a procedure.
DroppedStep,
/// Inverted the direction of a relationship.
ReversedRelationship,
/// Used a procedure that does not apply here.
WrongProcedure,
/// Right method, wrong sign or order of magnitude.
SignOrMagnitudeError,
/// Ignored a condition that interacts with the answer.
IgnoresInteractingCondition,
/// Correct as far as it goes, but not the best answer.
CorrectButIncomplete,
/// Took a heuristic shortcut that usually works.
CommonShortcut,
}
impl ErrorType {
/// Every error type.
pub const ALL: [ErrorType; 13] = [
ErrorType::RecallConfusion,
ErrorType::TerminologySwap,
ErrorType::Misconception,
ErrorType::IncompleteUnderstanding,
ErrorType::Overgeneralization,
ErrorType::PlausibleIrrelevant,
ErrorType::DroppedStep,
ErrorType::ReversedRelationship,
ErrorType::WrongProcedure,
ErrorType::SignOrMagnitudeError,
ErrorType::IgnoresInteractingCondition,
ErrorType::CorrectButIncomplete,
ErrorType::CommonShortcut,
];
/// The snake_case token used in YAML.
pub fn as_str(self) -> &'static str {
use ErrorType as E;
match self {
E::RecallConfusion => "recall_confusion",
E::TerminologySwap => "terminology_swap",
E::Misconception => "misconception",
E::IncompleteUnderstanding => "incomplete_understanding",
E::Overgeneralization => "overgeneralization",
E::PlausibleIrrelevant => "plausible_irrelevant",
E::DroppedStep => "dropped_step",
E::ReversedRelationship => "reversed_relationship",
E::WrongProcedure => "wrong_procedure",
E::SignOrMagnitudeError => "sign_or_magnitude_error",
E::IgnoresInteractingCondition => "ignores_interacting_condition",
E::CorrectButIncomplete => "correct_but_incomplete",
E::CommonShortcut => "common_shortcut",
}
}
/// A short instructor-facing gloss.
pub fn gloss(self) -> &'static str {
use ErrorType as E;
match self {
E::RecallConfusion => "confused with a neighboring fact",
E::TerminologySwap => "swapped similar terms",
E::Misconception => "specific wrong model",
E::IncompleteUnderstanding => "partial grasp of the idea",
E::Overgeneralization => "applied a rule outside its scope",
E::PlausibleIrrelevant => "true but not what was asked",
E::DroppedStep => "skipped a step",
E::ReversedRelationship => "reversed the direction",
E::WrongProcedure => "used the wrong procedure",
E::SignOrMagnitudeError => "sign or magnitude slip",
E::IgnoresInteractingCondition => "ignored an interacting condition",
E::CorrectButIncomplete => "correct but not best",
E::CommonShortcut => "took a familiar shortcut",
}
}
}
/// Where an item sits in the authoring workflow.
///
/// The state gates what the validator requires. A draft may be a bare idea; an
/// approved item must be fully sourced and designed, because approval is what
/// permits it onto a graded assessment. Retired items are kept forever so the
/// bank is an append-only record of what you have asked students.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
/// Captured but not yet worked out.
Draft,
/// Written and awaiting review.
InReview,
/// Reviewed and sent back for changes.
NeedsRevision,
/// Cleared for use on a graded assessment.
Approved,
/// Withdrawn from use but retained for the record.
Retired,
}
impl Status {
/// Whether an item in this state may appear on a graded assessment.
pub fn is_usable(self) -> bool {
matches!(self, Status::Approved)
}
/// The snake_case token used in YAML.
pub fn as_str(self) -> &'static str {
match self {
Status::Draft => "draft",
Status::InReview => "in_review",
Status::NeedsRevision => "needs_revision",
Status::Approved => "approved",
Status::Retired => "retired",
}
}
}
impl fmt::Display for Status {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// The response format of an item.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Format {
/// Exactly one keyed option.
SingleBestAnswer,
/// One or more keyed options; the student must find every one.
MultipleResponse,
/// Two options, True and False.
TrueFalse,
}
impl Format {
/// 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",
}
}
}
/// How strongly an item is expected to separate strong from weak students.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Discrimination {
/// Most prepared students get it; it anchors rather than separates.
Low,
/// Separates somewhat.
Moderate,
/// Expected to separate sharply.
High,
}
impl Discrimination {
/// The point-biserial band this expectation implies, as `(low, high)`.
///
/// Used to check an a priori expectation against the observed statistic.
pub fn expected_band(self) -> (f64, f64) {
match self {
Discrimination::Low => (-1.0, 0.20),
Discrimination::Moderate => (0.15, 0.40),
Discrimination::High => (0.30, 1.0),
}
}
}
/// What was done about an item after reviewing its statistics.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewAction {
/// Behaved as intended; leave it alone.
Keep,
/// Rewrite before reusing.
Revise,
/// Credit a defensible distractor for this administration.
AwardPartialCredit,
/// The key was wrong; fix it and rescore.
CorrectKey,
/// Withdraw from use.
Retire,
/// Keep but watch on the next administration.
Monitor,
}
/// A machine-detected problem with an item's observed behavior.
///
/// These are written by analysis, not by hand, and they are the queue you work
/// through when deciding what to revise.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Flag {
/// Weaker students outperformed stronger ones. Almost always a keying error
/// or a genuinely ambiguous stem.
NegativeDiscrimination,
/// Barely separates students.
LowDiscrimination,
/// A distractor correlates with total score better than the key does.
DistractorOutperformsKey,
/// Strong students chose one particular distractor at a high rate, which is
/// the signature of a second defensible reading.
KeyUnderperforms,
/// Nearly everyone answered correctly; carries little information.
TooEasy,
/// Nearly everyone answered incorrectly and it did not discriminate.
TooHard,
/// Many responses were faster than plausible reading time.
HighRapidGuess,
/// A distractor almost nobody chose; it is doing no work.
NonfunctioningDistractor,
/// Performance differed across groups after conditioning on ability.
DifFlagged,
/// Marked ambiguous by hand or inferred from partial credit awarded to a
/// distractor during grading.
Ambiguous,
/// The observed difficulty was far from the difficulty you predicted.
DesignMismatch,
}
impl Flag {
/// Every flag.
pub const ALL: [Flag; 11] = [
Flag::NegativeDiscrimination,
Flag::LowDiscrimination,
Flag::DistractorOutperformsKey,
Flag::KeyUnderperforms,
Flag::TooEasy,
Flag::TooHard,
Flag::HighRapidGuess,
Flag::NonfunctioningDistractor,
Flag::DifFlagged,
Flag::Ambiguous,
Flag::DesignMismatch,
];
/// The flag's stable code, matching its YAML spelling.
pub fn as_str(self) -> &'static str {
match self {
Flag::NegativeDiscrimination => "negative_discrimination",
Flag::LowDiscrimination => "low_discrimination",
Flag::DistractorOutperformsKey => "distractor_outperforms_key",
Flag::KeyUnderperforms => "key_underperforms",
Flag::TooEasy => "too_easy",
Flag::TooHard => "too_hard",
Flag::HighRapidGuess => "high_rapid_guess",
Flag::NonfunctioningDistractor => "nonfunctioning_distractor",
Flag::DifFlagged => "dif_flagged",
Flag::Ambiguous => "ambiguous",
Flag::DesignMismatch => "design_mismatch",
}
}
/// Whether the flag should stop an item from being reused as written.
///
/// Distinguishing blocking from advisory flags is what turns analysis into a
/// workflow: a negative discrimination is a keying bug to fix before the item
/// is ever given again, while an easy item is merely uninformative.
///
/// # Returns
///
/// `true` for flags that demand a revision.
pub fn is_blocking(self) -> bool {
matches!(
self,
Flag::NegativeDiscrimination
| Flag::DistractorOutperformsKey
| Flag::Ambiguous
| Flag::KeyUnderperforms
)
}
/// A short explanation of what the flag means and what to do about it.
pub fn advice(self) -> &'static str {
match self {
Flag::NegativeDiscrimination => {
"check the key first, then the stem for a second valid reading"
}
Flag::LowDiscrimination => "expected for anchors; investigate if the level is 3+",
Flag::DistractorOutperformsKey => "the distractor may be the better answer",
Flag::KeyUnderperforms => "strong students split; look for an ambiguity",
Flag::TooEasy => "fine as an anchor, wasteful if you meant it to discriminate",
Flag::TooHard => "check for a missing prerequisite or an unclear stem",
Flag::HighRapidGuess => "position on the form or time pressure, not the item",
Flag::NonfunctioningDistractor => "replace it with a plausible error",
Flag::DifFlagged => "inspect wording for content unrelated to the objective",
Flag::Ambiguous => "rewrite the stem to exclude the second reading",
Flag::DesignMismatch => "update your expectation or revise the item",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levels_order_by_demand() {
assert!(Level::Remember < Level::Create);
assert_eq!(Level::Apply.code(), 3);
}
#[test]
fn every_process_belongs_to_exactly_one_level() {
let mut seen = Vec::new();
for level in Level::ALL {
for p in level.processes() {
assert!(!seen.contains(p), "{p} appears under two levels");
seen.push(*p);
assert_eq!(p.level(), level);
}
}
assert_eq!(seen.len(), 19, "all processes are assigned");
}
#[test]
fn level_process_pairing_is_checked() {
assert!(Level::Apply.allows(CognitiveProcess::Implement));
assert!(!Level::Apply.allows(CognitiveProcess::Recall));
}
#[test]
fn level_serializes_as_an_integer() {
assert_eq!(serde_json::to_string(&Level::Apply).unwrap(), "3");
assert_eq!(serde_json::from_str::<Level>("4").unwrap(), Level::Analyze);
assert!(serde_json::from_str::<Level>("6").is_err());
assert!(serde_json::from_str::<Level>("0").is_err());
}
#[test]
fn processes_use_snake_case() {
assert_eq!(
serde_json::to_string(&CognitiveProcess::Implement).unwrap(),
"\"implement\""
);
assert_eq!(
serde_json::from_str::<Status>("\"in_review\"").unwrap(),
Status::InReview
);
}
}
+17
View File
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Small self-contained utilities with no knowledge of courses or assessments.
//!
//! Everything here exists because pulling in a crate for it was the worse trade.
//! Each module is a few hundred lines of well-understood algorithm, and each
//! replaces a dependency that would otherwise need to keep working for as long as
//! a course repository needs to stay readable.
pub mod date;
pub mod hash;
pub mod markup;
pub mod rng;
pub mod yaml;
pub mod zipfile;
+252
View File
@@ -0,0 +1,252 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! A minimal calendar date, serialized as `YYYY-MM-DD`.
//!
//! Course data is full of dates: when a lecture ran, when an item was authored,
//! when an exam was administered. Those dates need to sort, subtract, and round
//! trip through YAML exactly as written, but they never need time zones or
//! clock time. That is a small enough job to do without a dependency, so this
//! module implements it directly on top of the proleptic Gregorian calendar.
//!
//! The derived [`Ord`] is chronological because the fields are declared
//! most-significant first.
use std::fmt;
use std::str::FromStr;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::{Error, Result};
/// A calendar date with no time or zone.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Date {
/// Proleptic Gregorian year.
pub year: i32,
/// Month, 1 through 12.
pub month: u32,
/// Day of month, 1 through the length of the month.
pub day: u32,
}
impl Date {
/// Builds a date, checking that it exists on the calendar.
///
/// # Arguments
///
/// * `year` - the proleptic Gregorian year.
/// * `month` - month, 1 through 12.
/// * `day` - day of month.
///
/// # Returns
///
/// The date.
///
/// # Errors
///
/// Returns [`Error::BadDate`] when the month or day is out of range,
/// including February 30 and non-leap February 29.
pub fn new(year: i32, month: u32, day: u32) -> Result<Date> {
if !(1..=12).contains(&month) || day < 1 || day > days_in_month(year, month) {
return Err(Error::BadDate(format!("{year:04}-{month:02}-{day:02}")));
}
Ok(Date { year, month, day })
}
/// Today's date in UTC, read from the system clock.
///
/// UTC rather than local time keeps the value reproducible on any machine
/// that touches the course repository, which matters because these dates
/// end up committed.
///
/// # Returns
///
/// Today's date, or 1970-01-01 if the clock is set before the epoch.
pub fn today() -> Date {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
Date::from_days_since_epoch(secs.div_euclid(86_400))
}
/// Days since 1970-01-01, negative before it.
///
/// Uses Howard Hinnant's `days_from_civil`, which is exact for the whole
/// proleptic Gregorian range.
///
/// # Returns
///
/// The signed day count.
pub fn days_since_epoch(&self) -> i64 {
let y = if self.month <= 2 {
self.year as i64 - 1
} else {
self.year as i64
};
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let m = self.month as i64;
let d = self.day as i64;
let mp = if m > 2 { m - 3 } else { m + 9 };
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
/// The inverse of [`Date::days_since_epoch`].
///
/// # Arguments
///
/// * `z` - days since 1970-01-01.
///
/// # Returns
///
/// The corresponding date.
pub fn from_days_since_epoch(z: i64) -> Date {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
Date {
year: (if m <= 2 { y + 1 } else { y }) as i32,
month: m as u32,
day: d as u32,
}
}
/// Whole days from `self` to `other`, positive when `other` is later.
///
/// # Arguments
///
/// * `other` - the date to measure to.
///
/// # Returns
///
/// The signed difference in days.
pub fn days_until(&self, other: Date) -> i64 {
other.days_since_epoch() - self.days_since_epoch()
}
}
/// Length of a month, accounting for leap years.
fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if is_leap(year) => 29,
2 => 28,
_ => 0,
}
}
/// Whether a proleptic Gregorian year is a leap year.
fn is_leap(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
impl fmt::Display for Date {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
}
impl FromStr for Date {
type Err = Error;
fn from_str(s: &str) -> Result<Date> {
let t = s.trim();
let parts: Vec<&str> = t.split('-').collect();
if parts.len() != 3 {
return Err(Error::BadDate(t.to_string()));
}
let year: i32 = parts[0]
.parse()
.map_err(|_| Error::BadDate(t.to_string()))?;
let month: u32 = parts[1]
.parse()
.map_err(|_| Error::BadDate(t.to_string()))?;
let day: u32 = parts[2]
.parse()
.map_err(|_| Error::BadDate(t.to_string()))?;
Date::new(year, month, day)
}
}
impl Serialize for Date {
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Date {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Date, D::Error> {
struct V;
impl<'a> Visitor<'a> for V {
type Value = Date;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a date in YYYY-MM-DD form")
}
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<Date, E> {
v.parse::<Date>().map_err(de::Error::custom)
}
}
d.deserialize_str(V)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_through_string() {
let d: Date = "2026-04-23".parse().unwrap();
assert_eq!(d.year, 2026);
assert_eq!(d.month, 4);
assert_eq!(d.day, 23);
assert_eq!(d.to_string(), "2026-04-23");
}
#[test]
fn epoch_and_back() {
for iso in ["1970-01-01", "2000-02-29", "2026-04-23", "1969-12-31"] {
let d: Date = iso.parse().unwrap();
assert_eq!(
Date::from_days_since_epoch(d.days_since_epoch()),
d,
"{iso}"
);
}
assert_eq!("1970-01-01".parse::<Date>().unwrap().days_since_epoch(), 0);
}
#[test]
fn rejects_impossible_dates() {
assert!("2026-02-30".parse::<Date>().is_err());
assert!("2025-02-29".parse::<Date>().is_err());
assert!("2024-02-29".parse::<Date>().is_ok());
assert!("2026-13-01".parse::<Date>().is_err());
assert!("2026-4-23-1".parse::<Date>().is_err());
}
#[test]
fn orders_chronologically() {
let a: Date = "2025-12-31".parse().unwrap();
let b: Date = "2026-01-01".parse().unwrap();
assert!(a < b);
assert_eq!(a.days_until(b), 1);
assert_eq!(b.days_until(a), -1);
}
}
+257
View File
@@ -0,0 +1,257 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Content fingerprints and student pseudonyms.
//!
//! Two different jobs need two different hashes, and conflating them would be a
//! privacy bug.
//!
//! [`fingerprint`] answers "is this the same question I used last time?". It
//! only needs to be stable and short, so it uses FNV-1a. It is not a security
//! primitive and is never applied to anything about a person.
//!
//! [`pseudonym`] answers "can I keep a response table under version control
//! without publishing who answered what?". Student identifiers are low entropy
//! (a seven-digit number is a ten-million-item dictionary), so an unkeyed hash
//! of one is trivially reversible and would provide no protection at all. It
//! therefore uses HMAC-SHA-256 under a secret salt that lives outside the
//! repository. Both primitives are implemented here so the crate needs no
//! cryptography dependency.
/// FNV-1a 64-bit offset basis.
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
/// FNV-1a 64-bit prime.
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
/// A short, stable content fingerprint rendered as 16 lowercase hex digits.
///
/// Used to detect that a question was silently edited between two
/// administrations, which invalidates pooling their statistics.
///
/// # Arguments
///
/// * `parts` - the canonical content pieces, hashed in order with a separator
/// so that reordering or regrouping them changes the result.
///
/// # Returns
///
/// The fingerprint as a hex string.
pub fn fingerprint<'a, I>(parts: I) -> String
where
I: IntoIterator<Item = &'a str>,
{
let mut h = FNV_OFFSET;
for part in parts {
for b in part.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(FNV_PRIME);
}
// A byte that cannot appear in the inputs, so concatenation is unambiguous.
h ^= 0x1f;
h = h.wrapping_mul(FNV_PRIME);
}
format!("{h:016x}")
}
/// A keyed pseudonym for a student identifier.
///
/// # Arguments
///
/// * `salt` - a secret of at least 16 bytes, kept out of version control.
/// * `id` - the institutional identifier or email to replace.
/// * `len` - how many hex characters to keep; 16 gives a 64-bit tag, which is
/// ample for a cohort and short enough to read in a table.
///
/// # Returns
///
/// The truncated hex tag, prefixed with `s-`.
pub fn pseudonym(salt: &[u8], id: &str, len: usize) -> String {
let mac = hmac_sha256(salt, id.trim().to_lowercase().as_bytes());
let hex: String = mac.iter().map(|b| format!("{b:02x}")).collect();
format!("s-{}", &hex[..len.min(hex.len())])
}
/// HMAC-SHA-256.
///
/// # Arguments
///
/// * `key` - the secret key, of any length.
/// * `msg` - the message to authenticate.
///
/// # Returns
///
/// The 32-byte tag.
pub fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
const BLOCK: usize = 64;
let mut k = [0u8; BLOCK];
if key.len() > BLOCK {
k[..32].copy_from_slice(&sha256(key));
} else {
k[..key.len()].copy_from_slice(key);
}
let mut inner = Vec::with_capacity(BLOCK + msg.len());
let mut outer = Vec::with_capacity(BLOCK + 32);
for b in k.iter() {
inner.push(b ^ 0x36);
outer.push(b ^ 0x5c);
}
inner.extend_from_slice(msg);
outer.extend_from_slice(&sha256(&inner));
sha256(&outer)
}
/// SHA-256 round constants.
#[rustfmt::skip]
const K: [u32; 64] = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
];
/// SHA-256 of a byte slice.
///
/// # Arguments
///
/// * `msg` - the message to digest.
///
/// # Returns
///
/// The 32-byte digest.
pub fn sha256(msg: &[u8]) -> [u8; 32] {
let mut h: [u32; 8] = [
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
0x5be0cd19,
];
// Pad to a multiple of 64 bytes: 0x80, zeros, then the 64-bit bit length.
let mut data = msg.to_vec();
let bit_len = (msg.len() as u64).wrapping_mul(8);
data.push(0x80);
while data.len() % 64 != 56 {
data.push(0);
}
data.extend_from_slice(&bit_len.to_be_bytes());
let mut w = [0u32; 64];
for chunk in data.chunks(64) {
for (i, w_i) in w.iter_mut().enumerate().take(16) {
let j = i * 4;
*w_i = u32::from_be_bytes([chunk[j], chunk[j + 1], chunk[j + 2], chunk[j + 3]]);
}
for i in 16..64 {
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
w[i] = w[i - 16]
.wrapping_add(s0)
.wrapping_add(w[i - 7])
.wrapping_add(s1);
}
let mut v = h;
for i in 0..64 {
let s1 = v[4].rotate_right(6) ^ v[4].rotate_right(11) ^ v[4].rotate_right(25);
let ch = (v[4] & v[5]) ^ ((!v[4]) & v[6]);
let t1 = v[7]
.wrapping_add(s1)
.wrapping_add(ch)
.wrapping_add(K[i])
.wrapping_add(w[i]);
let s0 = v[0].rotate_right(2) ^ v[0].rotate_right(13) ^ v[0].rotate_right(22);
let maj = (v[0] & v[1]) ^ (v[0] & v[2]) ^ (v[1] & v[2]);
let t2 = s0.wrapping_add(maj);
v[7] = v[6];
v[6] = v[5];
v[5] = v[4];
v[4] = v[3].wrapping_add(t1);
v[3] = v[2];
v[2] = v[1];
v[1] = v[0];
v[0] = t1.wrapping_add(t2);
}
for i in 0..8 {
h[i] = h[i].wrapping_add(v[i]);
}
}
let mut out = [0u8; 32];
for i in 0..8 {
out[i * 4..i * 4 + 4].copy_from_slice(&h[i].to_be_bytes());
}
out
}
/// Renders bytes as lowercase hex.
///
/// # Arguments
///
/// * `bytes` - the bytes to render.
///
/// # Returns
///
/// The hex string.
pub fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_matches_known_vectors() {
assert_eq!(
hex(&sha256(b"")),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
hex(&sha256(b"abc")),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
// Longer than one block, to exercise the multi-chunk path.
assert_eq!(
hex(&sha256(
b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"
)),
"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"
);
}
#[test]
fn hmac_matches_rfc4231_case_2() {
// RFC 4231 test case 2: key "Jefe", data "what do ya want for nothing?".
assert_eq!(
hex(&hmac_sha256(b"Jefe", b"what do ya want for nothing?")),
"5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
);
}
#[test]
fn fingerprint_is_order_sensitive_and_unambiguous() {
assert_ne!(fingerprint(["a", "b"]), fingerprint(["b", "a"]));
// Separator prevents "ab" + "c" colliding with "a" + "bc".
assert_ne!(fingerprint(["ab", "c"]), fingerprint(["a", "bc"]));
assert_eq!(fingerprint(["a", "b"]), fingerprint(["a", "b"]));
assert_eq!(fingerprint(["x"]).len(), 16);
}
#[test]
fn pseudonym_is_keyed_and_normalized() {
let a = pseudonym(b"salt-one-0123456", "4496395", 16);
let b = pseudonym(b"salt-two-0123456", "4496395", 16);
assert_ne!(a, b, "different salts must give different pseudonyms");
assert_eq!(
pseudonym(b"salt-one-0123456", " SCD62@pitt.edu ", 16),
pseudonym(b"salt-one-0123456", "scd62@pitt.edu", 16)
);
assert!(a.starts_with("s-"));
assert_eq!(a.len(), 18);
}
}
+382
View File
@@ -0,0 +1,382 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Converting the authoring markup into HTML, plain text, and Typst.
//!
//! Stems are written in a small markup that is a subset of Typst with a few
//! Markdown conveniences, because chemistry and biology questions need
//! subscripts, arrows, and Greek letters, and typing HTML entities into YAML by
//! hand is miserable.
//!
//! There is no regular expression engine here. Every rule is a scan, which keeps
//! the dependency list short and makes the escaping order explicit: HTML is
//! escaped *first*, then symbol substitutions run, so that a substitution
//! producing `&rarr;` is not itself escaped into `&amp;rarr;`.
/// Symbol substitutions, applied after HTML escaping.
///
/// Ordered longest-first within each family so `#sym.arrow.r` is not consumed by
/// a shorter prefix.
const SYMBOLS: &[(&str, &str, &str)] = &[
// (source token, html, plain text)
("#sym.gt.eq", "&ge;", "\u{2265}"),
("#sym.lt.eq", "&le;", "\u{2264}"),
("#sym.eq.not", "&ne;", "\u{2260}"),
("#sym.plus.minus", "&plusmn;", "\u{00b1}"),
("#sym.arrow.r", "&rarr;", "\u{2192}"),
("#sym.arrow.l", "&larr;", "\u{2190}"),
("#sym.arrow.lr", "&harr;", "\u{2194}"),
("#sym.rightarrow", "&rarr;", "\u{2192}"),
("#sym.leftarrow", "&larr;", "\u{2190}"),
("#sym.times", "&times;", "\u{00d7}"),
("#sym.dot", "&middot;", "\u{00b7}"),
("#sym.degree", "&deg;", "\u{00b0}"),
("#sym.infinity", "&infin;", "\u{221e}"),
("#sym.approx", "&asymp;", "\u{2248}"),
("#sym.alpha", "&alpha;", "\u{03b1}"),
("#sym.beta", "&beta;", "\u{03b2}"),
("#sym.gamma", "&gamma;", "\u{03b3}"),
("#sym.delta.cap", "&Delta;", "\u{0394}"),
("#sym.delta", "&delta;", "\u{03b4}"),
("#sym.epsilon", "&epsilon;", "\u{03b5}"),
("#sym.lambda", "&lambda;", "\u{03bb}"),
("#sym.mu", "&mu;", "\u{03bc}"),
("#sym.pi", "&pi;", "\u{03c0}"),
("#sym.sigma", "&sigma;", "\u{03c3}"),
("#sym.tau", "&tau;", "\u{03c4}"),
("#sym.phi", "&phi;", "\u{03c6}"),
("#sym.omega", "&omega;", "\u{03c9}"),
];
/// Converts authoring markup to an HTML fragment.
///
/// Handles paragraphs, bold, italic, inline code, subscripts, superscripts, and
/// the symbol table. Anything unrecognized passes through escaped, so a stray
/// `<script>` in a stem cannot become markup in a Canvas quiz.
///
/// # Arguments
///
/// * `src` - the authoring source.
///
/// # Returns
///
/// An HTML fragment, with each paragraph wrapped in `<p>`.
pub fn to_html(src: &str) -> String {
let escaped = escape_html(src);
let symbolized = apply_symbols(&escaped, true);
let inline = apply_inline(&symbolized);
let paragraphs: Vec<String> = inline
.split("\n\n")
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(|p| {
let joined = p
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
format!("<p>{joined}</p>")
})
.collect();
if paragraphs.is_empty() {
String::new()
} else {
paragraphs.join("\n")
}
}
/// Converts authoring markup to plain text.
///
/// Used for CSV columns, terminal output, and any place a fragment of HTML would
/// be noise.
///
/// # Arguments
///
/// * `src` - the authoring source.
///
/// # Returns
///
/// Plain text with markup removed and symbols rendered as Unicode.
pub fn to_plain(src: &str) -> String {
let symbolized = apply_symbols(src, false);
let mut out = strip_inline(&symbolized);
out = out
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
out.trim().to_string()
}
/// Passes authoring markup through for Typst.
///
/// The markup is already a Typst subset, so this only normalizes whitespace and
/// escapes the few characters Typst treats specially in content mode.
///
/// # Arguments
///
/// * `src` - the authoring source.
///
/// # Returns
///
/// Typst content-mode markup.
pub fn to_typst(src: &str) -> String {
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.
'@' => out.push_str("\\@"),
'<' => out.push_str("\\<"),
'>' => out.push_str("\\>"),
_ => out.push(ch),
}
}
out
}
/// Escapes the five XML-significant characters.
///
/// # Arguments
///
/// * `s` - the text to escape.
///
/// # Returns
///
/// The escaped text.
pub fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
_ => out.push(ch),
}
}
out
}
/// Applies the symbol table.
///
/// # Arguments
///
/// * `s` - the text.
/// * `html` - whether to emit HTML entities rather than Unicode.
///
/// # Returns
///
/// The substituted text.
fn apply_symbols(s: &str, html: bool) -> String {
let mut out = s.to_string();
for (token, entity, plain) in SYMBOLS {
if out.contains(token) {
out = out.replace(token, if html { entity } else { plain });
}
}
out
}
/// Applies inline markup rules, producing HTML.
///
/// # Arguments
///
/// * `s` - escaped, symbol-substituted text.
///
/// # Returns
///
/// The text with inline markup converted.
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>");
out = wrap_bracket(&out, "#sup[", "<sup>", "</sup>");
out = wrap_delimited(&out, "`", "<code>", "</code>");
out = wrap_delimited(&out, "**", "<strong>", "</strong>");
out = wrap_delimited(&out, "*", "<em>", "</em>");
out = wrap_delimited(&out, "_", "<em>", "</em>");
out
}
/// Removes inline markup without replacing it.
///
/// # Arguments
///
/// * `s` - the text.
///
/// # Returns
///
/// The text with markup delimiters stripped.
fn strip_inline(s: &str) -> String {
let mut out = s.to_string();
out = wrap_bracket(&out, "#sub[", "", "");
out = wrap_bracket(&out, "#sup[", "", "");
out = wrap_delimited(&out, "`", "", "");
out = wrap_delimited(&out, "**", "", "");
out = wrap_delimited(&out, "*", "", "");
out = wrap_delimited(&out, "_", "", "");
out
}
/// Replaces `open...]` spans with wrapped content.
///
/// # Arguments
///
/// * `s` - the text.
/// * `open` - the opening token, e.g. `"#sub["`.
/// * `pre` - text to emit before the content.
/// * `post` - text to emit after the content.
///
/// # Returns
///
/// The rewritten text. Unclosed spans are left alone.
fn wrap_bracket(s: &str, open: &str, pre: &str, post: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
loop {
match rest.find(open) {
None => {
out.push_str(rest);
return out;
}
Some(i) => {
let after = &rest[i + open.len()..];
match after.find(']') {
None => {
out.push_str(rest);
return out;
}
Some(j) => {
out.push_str(&rest[..i]);
out.push_str(pre);
out.push_str(&after[..j]);
out.push_str(post);
rest = &after[j + 1..];
}
}
}
}
}
}
/// Replaces paired `delim...delim` spans with wrapped content.
///
/// A delimiter with no partner is emitted literally, so an apostrophe-heavy stem
/// or a lone asterisk does not swallow the rest of the text.
///
/// # Arguments
///
/// * `s` - the text.
/// * `delim` - the delimiter, e.g. `"**"`.
/// * `pre` - text to emit before the content.
/// * `post` - text to emit after the content.
///
/// # Returns
///
/// The rewritten text.
fn wrap_delimited(s: &str, delim: &str, pre: &str, post: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
loop {
match rest.find(delim) {
None => {
out.push_str(rest);
return out;
}
Some(i) => {
let after = &rest[i + delim.len()..];
match after.find(delim) {
None => {
out.push_str(rest);
return out;
}
Some(0) => {
// Empty span such as `**`; emit literally and move on.
out.push_str(&rest[..i + delim.len()]);
rest = after;
}
Some(j) => {
out.push_str(&rest[..i]);
out.push_str(pre);
out.push_str(&after[..j]);
out.push_str(post);
rest = &after[j + delim.len()..];
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escapes_before_substituting() {
// The entity produced by the symbol table must survive escaping.
assert_eq!(to_html("a #sym.arrow.r b"), "<p>a &rarr; b</p>");
// A literal ampersand is escaped.
assert_eq!(to_html("Tris & HCl"), "<p>Tris &amp; HCl</p>");
}
#[test]
fn refuses_to_pass_through_html() {
let out = to_html("<script>alert(1)</script>");
assert!(!out.contains("<script>"));
assert!(out.contains("&lt;script&gt;"));
}
#[test]
fn converts_inline_markup() {
assert_eq!(to_html("**bold**"), "<p><strong>bold</strong></p>");
assert_eq!(to_html("*em*"), "<p><em>em</em></p>");
assert_eq!(to_html("`code`"), "<p><code>code</code></p>");
assert_eq!(to_html("H#sub[2]O"), "<p>H<sub>2</sub>O</p>");
assert_eq!(to_html("x#sup[2]"), "<p>x<sup>2</sup></p>");
}
#[test]
fn bold_wins_over_italic() {
assert_eq!(
to_html("**strong** and *weak*"),
"<p><strong>strong</strong> and <em>weak</em></p>"
);
}
#[test]
fn unpaired_delimiters_are_literal() {
assert_eq!(to_html("2 * 3 = 6"), "<p>2 * 3 = 6</p>");
assert_eq!(to_html("a_b"), "<p>a_b</p>");
}
#[test]
fn splits_paragraphs_and_joins_wrapped_lines() {
let out = to_html("first line\ncontinued\n\nsecond paragraph");
assert_eq!(out, "<p>first line continued</p>\n<p>second paragraph</p>");
}
#[test]
fn empty_input_yields_empty_output() {
assert_eq!(to_html(" \n "), "");
assert_eq!(to_plain(""), "");
}
#[test]
fn plain_text_uses_unicode_and_drops_markup() {
assert_eq!(to_plain("K#sub[m] #sym.approx 5 mM"), "Km \u{2248} 5 mM");
assert_eq!(to_plain("**bold** text"), "bold text");
}
#[test]
fn typst_escapes_reference_starters() {
assert_eq!(to_typst("a @ b"), "a \\@ b");
assert_eq!(to_typst("x < y"), "x \\< y");
}
}
+173
View File
@@ -0,0 +1,173 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! A small deterministic random number generator.
//!
//! Every random choice this tool makes must be reproducible: if you regenerate
//! form B of an exam a month later, it has to come out identically, or the
//! answer key you already printed is wrong. So there is no system entropy
//! anywhere in the crate. Seeds are explicit, and a seed can be derived from a
//! string like `"exam-4-2026s/form-B"` so the caller never has to invent one.
//!
//! The generator is SplitMix64: two lines of arithmetic, excellent statistical
//! properties for shuffling, and identical output on every platform.
/// A seeded SplitMix64 generator.
#[derive(Debug, Clone)]
pub struct Rng {
state: u64,
}
impl Rng {
/// Creates a generator from a numeric seed.
///
/// # Arguments
///
/// * `seed` - any value; every seed gives a distinct stream.
///
/// # Returns
///
/// The generator.
pub fn new(seed: u64) -> Rng {
Rng { state: seed }
}
/// Creates a generator from a label, so callers can seed on meaning.
///
/// # Arguments
///
/// * `label` - a stable string such as an assessment id plus a form id.
///
/// # Returns
///
/// The generator.
pub fn from_label(label: &str) -> Rng {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in label.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
Rng::new(h)
}
/// The next 64 random bits.
///
/// # Returns
///
/// A uniformly distributed `u64`.
pub fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
/// A uniform integer in `[0, n)`.
///
/// Rejection sampling removes the modulo bias, which matters because a
/// biased shuffle would systematically favor certain answer positions.
///
/// # Arguments
///
/// * `n` - the exclusive upper bound; returns 0 when `n` is 0.
///
/// # Returns
///
/// The sampled integer.
pub fn below(&mut self, n: u64) -> u64 {
if n == 0 {
return 0;
}
let zone = u64::MAX - (u64::MAX % n) - 1;
loop {
let x = self.next_u64();
if x <= zone {
return x % n;
}
}
}
/// A uniform float in `[0, 1)`.
///
/// # Returns
///
/// The sampled float.
pub fn unit(&mut self) -> f64 {
// 53 bits of mantissa is the whole precision of f64.
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
/// Shuffles a slice in place with a Fisher-Yates pass.
///
/// # Arguments
///
/// * `items` - the slice to permute.
pub fn shuffle<T>(&mut self, items: &mut [T]) {
if items.len() < 2 {
return;
}
for i in (1..items.len()).rev() {
let j = self.below(i as u64 + 1) as usize;
items.swap(i, j);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn same_seed_gives_same_stream() {
let a: Vec<u64> = (0..8).map(|_| Rng::new(42).next_u64()).collect();
assert!(a.iter().all(|x| *x == a[0]), "fresh generators agree");
let mut r1 = Rng::new(7);
let mut r2 = Rng::new(7);
for _ in 0..64 {
assert_eq!(r1.next_u64(), r2.next_u64());
}
}
#[test]
fn different_labels_diverge() {
let mut a = Rng::from_label("exam-4/form-A");
let mut b = Rng::from_label("exam-4/form-B");
assert_ne!(a.next_u64(), b.next_u64());
}
#[test]
fn shuffle_is_a_permutation_and_reproducible() {
let mut v: Vec<u32> = (0..50).collect();
let mut w = v.clone();
Rng::from_label("seed").shuffle(&mut v);
Rng::from_label("seed").shuffle(&mut w);
assert_eq!(v, w, "same label reproduces the same order");
let mut sorted = v.clone();
sorted.sort_unstable();
assert_eq!(sorted, (0..50).collect::<Vec<u32>>());
assert_ne!(v, sorted, "50 elements should not shuffle back to sorted");
}
#[test]
fn below_stays_in_range() {
let mut r = Rng::new(1);
for _ in 0..1000 {
assert!(r.below(5) < 5);
}
assert_eq!(r.below(1), 0);
assert_eq!(r.below(0), 0);
}
#[test]
fn unit_is_in_the_unit_interval() {
let mut r = Rng::new(3);
for _ in 0..1000 {
let u = r.unit();
assert!((0.0..1.0).contains(&u));
}
}
}
+228
View File
@@ -0,0 +1,228 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! Reading and writing the YAML and JSON files the tool owns.
//!
//! Two small conveniences live here. First, every read and write attaches the
//! path to its error, because "invalid type: found string" is useless without
//! knowing which of forty bank files produced it. Second, [`flexible_string`]
//! lets `schema_version: 1.0` parse as the string `"1.0"`. YAML reads an
//! unquoted `1.0` as a float, and being told to go back and add quotation marks
//! is a poor first experience of a schema.
use std::fmt;
use std::fs;
use std::path::Path;
use serde::de::{self, DeserializeOwned, Visitor};
use serde::{Deserializer, Serialize};
use crate::error::{Error, Result};
/// Deserializes a YAML file into any schema type.
///
/// # Arguments
///
/// * `path` - the file to read.
///
/// # Returns
///
/// The deserialized value.
///
/// # Errors
///
/// Returns [`Error::Io`] when the file cannot be read and [`Error::Yaml`] when
/// it does not match the target schema.
pub fn read<T: DeserializeOwned>(path: &Path) -> Result<T> {
let text = fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
serde_yaml_ng::from_str(&text).map_err(|source| Error::Yaml {
path: path.to_path_buf(),
source,
})
}
/// Serializes a value to a YAML file, creating parent directories as needed.
///
/// # Arguments
///
/// * `path` - the destination file.
/// * `value` - the value to write.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure, or [`Error::Other`] if the value
/// cannot be represented as YAML.
pub fn write<T: Serialize>(path: &Path, value: &T) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
}
let text = serde_yaml_ng::to_string(value).map_err(Error::other)?;
fs::write(path, text).map_err(|e| Error::io(path, e))
}
/// Deserializes a JSON file into any type.
///
/// Used only for importing legacy banks and for reading emitted schemas back in
/// tests; the tool's own files are YAML.
///
/// # Arguments
///
/// * `path` - the file to read.
///
/// # Returns
///
/// The deserialized value.
///
/// # Errors
///
/// Returns [`Error::Io`] or [`Error::Json`].
pub fn read_json<T: DeserializeOwned>(path: &Path) -> Result<T> {
let text = fs::read_to_string(path).map_err(|e| Error::io(path, e))?;
serde_json::from_str(&text).map_err(|source| Error::Json {
path: path.to_path_buf(),
source,
})
}
/// Writes a value as pretty-printed JSON.
///
/// # Arguments
///
/// * `path` - the destination file.
/// * `value` - the value to write.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure.
pub fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
}
let text = serde_json::to_string_pretty(value).map_err(Error::other)?;
fs::write(path, format!("{text}\n")).map_err(|e| Error::io(path, e))
}
/// Writes text to a file, creating parent directories as needed.
///
/// # Arguments
///
/// * `path` - the destination file.
/// * `text` - the contents.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure.
pub fn write_text(path: &Path, text: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
}
fs::write(path, text).map_err(|e| Error::io(path, e))
}
/// Lists the `*.yaml` and `*.yml` files in a directory, sorted by name.
///
/// Sorting makes every downstream output deterministic, which matters when the
/// outputs are committed.
///
/// # Arguments
///
/// * `dir` - the directory to scan.
///
/// # Returns
///
/// The paths, empty when the directory does not exist.
///
/// # Errors
///
/// Returns [`Error::Io`] when the directory exists but cannot be read.
pub fn list_yaml(dir: &Path) -> Result<Vec<std::path::PathBuf>> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
for entry in fs::read_dir(dir).map_err(|e| Error::io(dir, e))? {
let entry = entry.map_err(|e| Error::io(dir, e))?;
let path = entry.path();
let is_yaml = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("yaml") || e.eq_ignore_ascii_case("yml"))
.unwrap_or(false);
if is_yaml && path.is_file() {
out.push(path);
}
}
out.sort();
Ok(out)
}
/// Deserializes a scalar as a string, whether it was written quoted or not.
///
/// # Arguments
///
/// * `d` - the deserializer.
///
/// # Returns
///
/// The value as a string.
///
/// # Errors
///
/// Returns a deserialization error for non-scalar input.
pub fn flexible_string<'de, D>(d: D) -> std::result::Result<String, D::Error>
where
D: Deserializer<'de>,
{
struct V;
impl<'a> Visitor<'a> for V {
type Value = String;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a version such as \"1.0\"")
}
fn visit_str<E: de::Error>(self, v: &str) -> std::result::Result<String, E> {
Ok(v.to_string())
}
fn visit_f64<E: de::Error>(self, v: f64) -> std::result::Result<String, E> {
// 1.0 must render as "1.0", not "1".
Ok(format!("{v:.1}"))
}
fn visit_u64<E: de::Error>(self, v: u64) -> std::result::Result<String, E> {
Ok(format!("{v}.0"))
}
fn visit_i64<E: de::Error>(self, v: i64) -> std::result::Result<String, E> {
Ok(format!("{v}.0"))
}
}
d.deserialize_any(V)
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct Versioned {
#[serde(deserialize_with = "flexible_string")]
v: String,
}
#[test]
fn flexible_string_accepts_quoted_and_bare_versions() {
for (src, want) in [
("v: \"1.0\"", "1.0"),
("v: 1.0", "1.0"),
("v: 2", "2.0"),
("v: \"1.10\"", "1.10"),
] {
let got: Versioned = serde_yaml_ng::from_str(src).expect(src);
assert_eq!(got.v, want, "{src}");
}
}
}
+246
View File
@@ -0,0 +1,246 @@
// SPDX-License-Identifier: Prosperity-3.0.0
// Copyright Scientific Computing Studio
// Source: https://git.scient.ing/education/coursebank
//! A minimal ZIP writer, stored (uncompressed) entries only.
//!
//! Canvas needs a `.zip` to import a QTI package, and that is the only reason
//! this crate needs ZIP at all. Writing ~120 lines of well-understood format
//! beats taking a dependency whose API has changed shape several times, and it
//! buys two things worth having: the archives are byte-for-byte reproducible,
//! because the timestamp is fixed rather than read from the clock, and a QTI
//! package diffs cleanly in a course repository.
//!
//! Entries are stored rather than deflated. A quiz package is a few tens of
//! kilobytes of XML, so compression saves nothing that matters, and the
//! Gradescope and Canvas exports in the wild are stored too.
use std::io::Write;
use std::path::Path;
use crate::error::{Error, Result};
/// CRC-32 (IEEE 802.3) of a byte slice.
///
/// # Arguments
///
/// * `data` - the bytes to checksum.
///
/// # Returns
///
/// The checksum.
fn crc32(data: &[u8]) -> u32 {
let mut table = [0u32; 256];
for (i, entry) in table.iter_mut().enumerate() {
let mut c = i as u32;
for _ in 0..8 {
c = if c & 1 != 0 {
0xEDB8_8320 ^ (c >> 1)
} else {
c >> 1
};
}
*entry = c;
}
let mut crc = 0xFFFF_FFFFu32;
for b in data {
crc = table[((crc ^ *b as u32) & 0xFF) as usize] ^ (crc >> 8);
}
crc ^ 0xFFFF_FFFF
}
/// One file to place in the archive.
struct Entry {
/// The path inside the archive, always with forward slashes.
name: String,
/// The file contents.
data: Vec<u8>,
/// CRC-32 of `data`.
crc: u32,
/// Byte offset of this entry's local header.
offset: u32,
}
/// Builds a ZIP archive in memory.
#[derive(Default)]
pub struct ZipBuilder {
entries: Vec<Entry>,
body: Vec<u8>,
}
/// The fixed DOS timestamp used for every entry: 1980-01-01 00:00:00.
///
/// A real clock value would make otherwise identical packages differ, which
/// defeats the point of committing them.
const DOS_TIME: u16 = 0;
/// The DOS date for 1980-01-01.
const DOS_DATE: u16 = 0x0021;
impl ZipBuilder {
/// Creates an empty archive.
pub fn new() -> ZipBuilder {
ZipBuilder::default()
}
/// Adds a file to the archive.
///
/// # Arguments
///
/// * `name` - the path inside the archive.
/// * `data` - the contents.
pub fn add(&mut self, name: &str, data: impl Into<Vec<u8>>) {
let data = data.into();
let crc = crc32(&data);
let offset = self.body.len() as u32;
let name = name.replace('\\', "/");
let name_bytes = name.as_bytes();
// Local file header.
self.body.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
self.body.extend_from_slice(&20u16.to_le_bytes()); // version needed
self.body.extend_from_slice(&0u16.to_le_bytes()); // flags
self.body.extend_from_slice(&0u16.to_le_bytes()); // method: stored
self.body.extend_from_slice(&DOS_TIME.to_le_bytes());
self.body.extend_from_slice(&DOS_DATE.to_le_bytes());
self.body.extend_from_slice(&crc.to_le_bytes());
self.body
.extend_from_slice(&(data.len() as u32).to_le_bytes());
self.body
.extend_from_slice(&(data.len() as u32).to_le_bytes());
self.body
.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
self.body.extend_from_slice(&0u16.to_le_bytes()); // extra field length
self.body.extend_from_slice(name_bytes);
self.body.extend_from_slice(&data);
self.entries.push(Entry {
name,
data,
crc,
offset,
});
}
/// Adds a text file to the archive.
///
/// # Arguments
///
/// * `name` - the path inside the archive.
/// * `text` - the contents.
pub fn add_text(&mut self, name: &str, text: &str) {
self.add(name, text.as_bytes().to_vec());
}
/// Serializes the archive.
///
/// # Returns
///
/// The complete ZIP file bytes.
pub fn finish(self) -> Vec<u8> {
let mut out = self.body;
let cd_offset = out.len() as u32;
for e in &self.entries {
let name = e.name.as_bytes();
out.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
out.extend_from_slice(&20u16.to_le_bytes()); // version made by
out.extend_from_slice(&20u16.to_le_bytes()); // version needed
out.extend_from_slice(&0u16.to_le_bytes()); // flags
out.extend_from_slice(&0u16.to_le_bytes()); // method: stored
out.extend_from_slice(&DOS_TIME.to_le_bytes());
out.extend_from_slice(&DOS_DATE.to_le_bytes());
out.extend_from_slice(&e.crc.to_le_bytes());
out.extend_from_slice(&(e.data.len() as u32).to_le_bytes());
out.extend_from_slice(&(e.data.len() as u32).to_le_bytes());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // extra
out.extend_from_slice(&0u16.to_le_bytes()); // comment
out.extend_from_slice(&0u16.to_le_bytes()); // disk number
out.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
out.extend_from_slice(&0u32.to_le_bytes()); // external attrs
out.extend_from_slice(&e.offset.to_le_bytes());
out.extend_from_slice(name);
}
let cd_size = out.len() as u32 - cd_offset;
// End of central directory.
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // this disk
out.extend_from_slice(&0u16.to_le_bytes()); // disk with cd
out.extend_from_slice(&(self.entries.len() as u16).to_le_bytes());
out.extend_from_slice(&(self.entries.len() as u16).to_le_bytes());
out.extend_from_slice(&cd_size.to_le_bytes());
out.extend_from_slice(&cd_offset.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // comment length
out
}
/// Writes the archive to a file.
///
/// # Arguments
///
/// * `path` - the destination.
///
/// # Errors
///
/// Returns [`Error::Io`] on a write failure.
pub fn write_to(self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
}
let bytes = self.finish();
let mut f = std::fs::File::create(path).map_err(|e| Error::io(path, e))?;
f.write_all(&bytes).map_err(|e| Error::io(path, e))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crc32_matches_the_known_vector() {
// The canonical check value for CRC-32/ISO-HDLC over "123456789".
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
assert_eq!(crc32(b""), 0);
}
#[test]
fn produces_a_recognizable_archive() {
let mut z = ZipBuilder::new();
z.add_text("imsmanifest.xml", "<manifest/>");
z.add_text("quiz.xml", "<questestinterop/>");
let bytes = z.finish();
assert_eq!(&bytes[0..4], b"PK\x03\x04", "starts with a local header");
// End-of-central-directory signature appears near the end.
let eocd = bytes
.windows(4)
.rposition(|w| w == b"PK\x05\x06")
.expect("has an end-of-central-directory record");
assert_eq!(
u16::from_le_bytes([bytes[eocd + 10], bytes[eocd + 11]]),
2,
"records two entries"
);
assert!(bytes.windows(15).any(|w| w == b"imsmanifest.xml"));
}
#[test]
fn output_is_byte_for_byte_reproducible() {
let build = || {
let mut z = ZipBuilder::new();
z.add_text("a.xml", "<a/>");
z.finish()
};
assert_eq!(build(), build());
}
#[test]
fn empty_archive_is_valid() {
let bytes = ZipBuilder::new().finish();
assert_eq!(&bytes[0..4], b"PK\x05\x06");
assert_eq!(bytes.len(), 22);
}
}
+220
View File
@@ -0,0 +1,220 @@
//! Checks the markdown that gets included into the documentation.
//!
//! Rustdoc reads a fenced block with an empty info string as Rust and compiles it.
//! So a `course.yaml` snippet in a bare fence fails the doc build with a parse
//! error pointing at a generated file, which is a confusing way to learn that a
//! four-letter language tag is missing. This test names the file and line instead.
//!
//! Two related problems are already covered elsewhere and are not repeated here: a
//! missing `include_str!` target fails compilation, and a broken intra-doc link
//! fails under `#![deny(rustdoc::broken_intra_doc_links)]` in `lib.rs`.
//!
//! No regex engine, matching the rest of the crate: every rule is a scan.
use std::path::{Path, PathBuf};
/// Info-string words rustdoc reads as attributes on a *Rust* block. Anything else
/// is a language name, and rustdoc skips the block.
const RUST_ATTRS: &[&str] = &[
"rust",
"ignore",
"should_panic",
"no_run",
"compile_fail",
"test_harness",
"standalone_crate",
];
/// Language tags this repository uses for content that is not Rust.
const KNOWN_LANGS: &[&str] = &[
"console", "text", "yaml", "toml", "json", "bash", "sh", "shell", "typst", "diff", "csv",
"markdown", "md", "xml", "html", "python",
];
/// The repository root, derived from the manifest directory.
fn root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
/// Every `.md` file under `docs/`, sorted so failures are reported in a stable
/// order.
fn markdown_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut paths: Vec<PathBuf> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
paths.sort();
for path in paths {
if path.is_dir() {
markdown_files(&path, out);
} else if path.extension().is_some_and(|e| e == "md") {
out.push(path);
}
}
}
/// A fence opener: its line number, its run of backticks, and its info string.
struct Fence<'a> {
line: usize,
ticks: usize,
info: &'a str,
}
/// Finds every opening fence, tracking nesting by backtick count so that a `` ``` ``
/// inside a ```` ```` ```` block is treated as content rather than as a new fence.
fn opening_fences(text: &str) -> (Vec<Fence<'_>>, Option<usize>) {
let mut found = Vec::new();
let mut open: Option<usize> = None;
for (index, raw) in text.lines().enumerate() {
let line = raw.trim_start();
if !line.starts_with("```") {
continue;
}
let ticks = line.chars().take_while(|c| *c == '`').count();
let info = line[ticks..].trim();
match open {
None => {
open = Some(ticks);
found.push(Fence {
line: index + 1,
ticks,
info,
});
}
// A closing fence is at least as long as its opener and carries no info
// string. Anything else is content inside the block.
Some(width) if ticks >= width && info.is_empty() => open = None,
Some(_) => {}
}
}
(found, open)
}
#[test]
fn every_code_fence_declares_its_language() {
let root = root();
let mut files = Vec::new();
markdown_files(&root.join("docs"), &mut files);
assert!(!files.is_empty(), "no markdown found under docs/");
let mut issues: Vec<String> = Vec::new();
for path in &files {
let text = std::fs::read_to_string(path).expect("markdown is readable");
let name = path.strip_prefix(&root).unwrap_or(path).display();
let (fences, unclosed) = opening_fences(&text);
if unclosed.is_some() {
issues.push(format!(
"{name}: a code fence is never closed. A nested ``` inside a block \
closes its parent early; widen the outer fence to ````."
));
}
for fence in fences {
let _ = fence.ticks;
if fence.info.is_empty() {
issues.push(format!(
"{name}:{}: fence has no language, so rustdoc compiles it as Rust. \
Tag it (```console, ```yaml, ```text) or mark it ```rust.",
fence.line
));
continue;
}
let words: Vec<&str> = fence
.info
.split(|c: char| c == ',' || c.is_whitespace())
.filter(|w| !w.is_empty())
.collect();
let head = words[0];
if RUST_ATTRS.contains(&head) || head.starts_with("edition") {
// A Rust block. Every further word must be a real attribute, since a
// typo like `no_ru` silently stops the example from being tested.
for word in &words {
if !RUST_ATTRS.contains(word) && !word.starts_with("edition") {
issues.push(format!(
"{name}:{}: `{word}` is not a rustdoc code attribute, so this \
block is silently not tested",
fence.line
));
}
}
} else if !KNOWN_LANGS.contains(&head) {
issues.push(format!(
"{name}:{}: unrecognized language `{head}`; add it to KNOWN_LANGS \
in tests/docs.rs if that is intended",
fence.line
));
}
}
}
assert!(
issues.is_empty(),
"{} problem(s) in included markdown:\n{}",
issues.len(),
issues
.iter()
.map(|i| format!(" - {i}"))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn the_guide_includes_every_markdown_file_under_docs() {
// A page nobody includes is a page nobody reads. This catches a new file in
// docs/ that was never wired into the module tree.
let root = root();
let guide = std::fs::read_to_string(root.join("src/guide.rs")).expect("src/guide.rs exists");
let lib = std::fs::read_to_string(root.join("src/lib.rs")).expect("src/lib.rs exists");
let included = format!("{guide}{lib}");
let mut files = Vec::new();
markdown_files(&root.join("docs"), &mut files);
let orphans: Vec<String> = files
.iter()
.filter(|path| {
let name = path.file_name().unwrap().to_string_lossy().to_string();
!included.contains(&name)
})
.map(|path| {
path.strip_prefix(&root)
.unwrap_or(path)
.display()
.to_string()
})
.collect();
assert!(
orphans.is_empty(),
"these files are not included by any doc attribute:\n{}\nAdd a doc-only module \
in src/guide.rs, or move the file out of docs/.",
orphans
.iter()
.map(|o| format!(" - {o}"))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn fence_scanning_handles_nesting_and_tags() {
let (fences, unclosed) = opening_fences("```yaml\na: 1\n```\n");
assert_eq!(fences.len(), 1);
assert_eq!(fences[0].info, "yaml");
assert!(unclosed.is_none());
// A ``` inside a ```` block is content, not a fence.
let (fences, unclosed) = opening_fences("````yaml\nbody: |\n ```\n table\n ```\n````\n");
assert_eq!(fences.len(), 1, "the inner fence should not open a block");
assert!(unclosed.is_none());
let (_, unclosed) = opening_fences("```text\nno end\n");
assert!(unclosed.is_some());
}