diff --git a/.bumpversion.toml b/.bumpversion.toml new file mode 100644 index 0000000..698f98b --- /dev/null +++ b/.bumpversion.toml @@ -0,0 +1,29 @@ +[tool.bumpversion] +current_version = "26.8.0" +parse = """(?x) + (?P + (?:[1-9][0-9]?)\\. # YY short year, no leading zero + (?:1[0-2]|[1-9]) # MM month 1-12, no leading zero + ) + \\.(?P\\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}"' diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2d9cf5e --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..c165217 --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +watch_file pixi.lock +eval "$(pixi shell-hook -e dev)" diff --git a/.gitea/notice.md b/.gitea/notice.md new file mode 100644 index 0000000..0290cae --- /dev/null +++ b/.gitea/notice.md @@ -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. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..abb8b88 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitea/workflows/docs.yml b/.gitea/workflows/docs.yml new file mode 100644 index 0000000..0a33ea6 --- /dev/null +++ b/.gitea/workflows/docs.yml @@ -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' +
+ Docs channel: + release| + nightly + +
+ 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' "" > 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' "" > "$root/index.html" diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml new file mode 100644 index 0000000..01c43e7 --- /dev/null +++ b/.gitea/workflows/nightly.yml @@ -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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..38a59d4 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/.gitea/workflows/sync-readme.yml b/.gitea/workflows/sync-readme.yml new file mode 100644 index 0000000..63993b3 --- /dev/null +++ b/.gitea/workflows/sync-readme.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8181a1e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..4ad92a0 --- /dev/null +++ b/.ignore @@ -0,0 +1,5 @@ +.pixi +.DS_Store +**/cache/* +**/fonts/* +target diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..809e495 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..421d1e9 --- /dev/null +++ b/Cargo.toml @@ -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 "] +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"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..94e4498 --- /dev/null +++ b/LICENSE.md @@ -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.*** \ No newline at end of file diff --git a/README.md b/README.md index e69de29..67a3cb9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..60747f8 --- /dev/null +++ b/RELEASING.md @@ -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 ` 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. diff --git a/about.hbs b/about.hbs new file mode 100644 index 0000000..f0e5a83 --- /dev/null +++ b/about.hbs @@ -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}} \ No newline at end of file diff --git a/about.toml b/about.toml new file mode 100644 index 0000000..1228785 --- /dev/null +++ b/about.toml @@ -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", +] \ No newline at end of file diff --git a/docs/TYPST.md b/docs/TYPST.md new file mode 100644 index 0000000..00e5822 --- /dev/null +++ b/docs/TYPST.md @@ -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 `, or `template:` in the render config +2. `templates/-.typ`, a one-off layout for one exam +3. `templates/.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 diff --git a/docs/guide/authoring.md b/docs/guide/authoring.md new file mode 100644 index 0000000..de581b3 --- /dev/null +++ b/docs/guide/authoring.md @@ -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. diff --git a/docs/guide/first_exam.md b/docs/guide/first_exam.md new file mode 100644 index 0000000..ac42247 --- /dev/null +++ b/docs/guide/first_exam.md @@ -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(()) +# } +``` diff --git a/docs/guide/recipes.md b/docs/guide/recipes.md new file mode 100644 index 0000000..6238715 --- /dev/null +++ b/docs/guide/recipes.md @@ -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. diff --git a/docs/guide/setup.md b/docs/guide/setup.md new file mode 100644 index 0000000..12782ec --- /dev/null +++ b/docs/guide/setup.md @@ -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(()) +# } +``` diff --git a/pixi.lock b/pixi.lock new file mode 100644 index 0000000..229bc73 --- /dev/null +++ b/pixi.lock @@ -0,0 +1,3187 @@ +version: 7 +platforms: +- name: linux-64 + virtual-packages: + - __unix=0=0 + - __linux=4.18 + - __glibc=2.28 + - __archspec=0=x86_64 +- name: osx-64 + virtual-packages: + - __unix=0=0 + - __osx=13.0 + - __archspec=0=x86_64 +- name: osx-arm64 + virtual-packages: + - __unix=0=0 + - __osx=13.0 + - __archspec=0=m1 +environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils-2.46.1-default_h4852527_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.46.1-default_hfdba357_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_linux-64-2.46.1-default_h4852527_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/c-compiler-1.11.0-h4d9bdce_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/conda-gcc-specs-14.4.0-h611768e_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc-14.4.0-hc6a0c74_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-14.4.0-h9711763_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_linux-64-14.4.0-hc469d41_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-16.1.0-h69a702a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-14.4.0-hf39dbba_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.1.0-h934c35e_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pkg-config-0.29.2-h4bc722e_1009.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.96.1-h53717f1_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-14.4.0-hd9a9cd0_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-14.4.0-ha5b54cb_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.96.1-h2c6d0dc_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + osx-64: + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-64-19.1.7-h138dee1_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-apple-darwin-1.96.1-h38e4360_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sdkroot_env_osx-64-26.0-h62b880e_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/c-compiler-1.11.0-h7a00415_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/cctools-1030.6.3-llvm19_1_h67a6458_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/cctools_impl_osx-64-1030.6.3-llvm19_1_h6ae9dcd_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/cctools_osx-64-1030.6.3-llvm19_1_h67a6458_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/clang-19-19.1.7-default_h9399c5b_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/clang-19.1.7-default_h1323312_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/clang_impl_osx-64-19.1.7-default_ha1a018a_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/clang_osx-64-19.1.7-h8a78ed7_34.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/compiler-rt-19.1.7-he914875_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ld64-956.6-llvm19_1_hc3792c1_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ld64_osx-64-956.6-llvm19_1_hf4e8e46_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libclang-cpp19.1-19.1.7-default_h9399c5b_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-22.1.8-h19cb2f5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libiconv-1.18-h57a12c2_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libllvm19-19.1.7-h56e7563_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libsigtool-0.1.3-hc0f2934_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-16-2.15.3-h0d7f165_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-2.15.3-h0712280_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-openmp-22.1.8-h0d3cbff_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-tools-19-19.1.7-h879f4bc_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-tools-19.1.7-hb0207f0_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.3-hc881268_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pkg-config-0.29.2-hf7e621a_1009.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/rust-1.96.1-h5655b98_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/sigtool-codesign-0.1.3-hc0f2934_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/tapi-1600.0.11.8-h44950e2_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-arm64-19.1.7-he32a8d3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.96.1-hf6ec828_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sdkroot_env_osx-arm64-26.0-ha3f98da_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-compiler-1.11.0-h61f9b84_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools-1030.6.3-llvm19_1_hd01ab73_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools_impl_osx-arm64-1030.6.3-llvm19_1_hc7668c6_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools_osx-arm64-1030.6.3-llvm19_1_hd01ab73_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang-19-19.1.7-default_hf3020a7_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang-19.1.7-default_hf9bcbb7_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang_impl_osx-arm64-19.1.7-default_hc11f16d_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang_osx-arm64-19.1.7-h75f8d18_34.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/compiler-rt-19.1.7-h855ad52_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ld64-956.6-llvm19_1_he86490a_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ld64_osx-arm64-956.6-llvm19_1_ha2625f7_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libclang-cpp19.1-19.1.7-default_hf3020a7_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.88.3-ha08bb59_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libllvm19-19.1.7-h8e0c9ce_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsigtool-0.1.3-h98dc951_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.3-h6967ea9_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-heed7d32_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-22.1.8-hc7d1edf_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-19-19.1.7-h91fd4e7_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-19.1.7-h855ad52_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-h30297fc_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pkg-config-0.29.2-hde07d2e_1009.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.96.1-h4ff7c5d_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sigtool-codesign-0.1.3-h98dc951_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tapi-1600.0.11.8-hb561403_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + dev: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils-2.46.1-default_h4852527_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.46.1-default_hfdba357_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_linux-64-2.46.1-default_h4852527_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/c-compiler-1.11.0-h4d9bdce_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cargo-nextest-0.9.143-hb17b654_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/conda-gcc-specs-14.4.0-h611768e_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc-14.4.0-hc6a0c74_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-14.4.0-h9711763_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_linux-64-14.4.0-hc469d41_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp22.1-22.1.8-default_h6c227bf_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-16.1.0-h69a702a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libllvm22-22.1.8-hf7376ad_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-14.4.0-hf39dbba_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.1.0-h934c35e_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lldb-22.1.8-py314ha98bd89_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pkg-config-0.29.2-h4bc722e_1009.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.96.1-h53717f1_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/rust-analyzer-2026.04.27-hb17b654_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd70dff1_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-14.4.0-hd9a9cd0_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-14.4.0-ha5b54cb_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.96.1-h2c6d0dc_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + osx-64: + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-64-19.1.7-h138dee1_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-apple-darwin-1.96.1-h38e4360_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sdkroot_env_osx-64-26.0-h62b880e_7.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h374f1ed_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/c-compiler-1.11.0-h7a00415_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/cargo-nextest-0.9.143-h19f9e61_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/cctools-1030.6.3-llvm19_1_h67a6458_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/cctools_impl_osx-64-1030.6.3-llvm19_1_h6ae9dcd_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/cctools_osx-64-1030.6.3-llvm19_1_h67a6458_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/clang-19-19.1.7-default_h9399c5b_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/clang-19.1.7-default_h1323312_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/clang_impl_osx-64-19.1.7-default_ha1a018a_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/clang_osx-64-19.1.7-h8a78ed7_34.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/compiler-rt-19.1.7-he914875_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ld64-956.6-llvm19_1_hc3792c1_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ld64_osx-64-956.6-llvm19_1_hf4e8e46_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libclang-cpp19.1-19.1.7-default_h9399c5b_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libclang-cpp22.1-22.1.8-default_h5a1b869_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-22.1.8-h19cb2f5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libedit-3.1.20250104-pl5321ha958ccf_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.8.1-hcc62823_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-hd1f9c09_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libiconv-1.18-h57a12c2_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libllvm19-19.1.7-h56e7563_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libllvm22-22.1.8-hab754da_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-hf3981d6_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libsigtool-0.1.3-hc0f2934_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.53.4-h77d7759_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-16-2.15.3-h0d7f165_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-2.15.3-h0712280_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/lldb-22.1.8-py314h8d3d9e6_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-openmp-22.1.8-h0d3cbff_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-tools-19-19.1.7-h879f4bc_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-tools-19.1.7-hb0207f0_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.3-hc881268_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pkg-config-0.29.2-hf7e621a_1009.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.14.6-h7c6738f_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/rust-1.96.1-h5655b98_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/rust-analyzer-2026.04.27-h19f9e61_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/sigtool-codesign-0.1.3-hc0f2934_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/tapi-1600.0.11.8-h44950e2_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-hb794df6_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-arm64-19.1.7-he32a8d3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.96.1-hf6ec828_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sdkroot_env_osx-arm64-26.0-ha3f98da_7.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-compiler-1.11.0-h61f9b84_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cargo-nextest-0.9.143-h6fdd925_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools-1030.6.3-llvm19_1_hd01ab73_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools_impl_osx-arm64-1030.6.3-llvm19_1_hc7668c6_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools_osx-arm64-1030.6.3-llvm19_1_hd01ab73_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang-19-19.1.7-default_hf3020a7_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang-19.1.7-default_hf9bcbb7_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang_impl_osx-arm64-19.1.7-default_hc11f16d_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang_osx-arm64-19.1.7-h75f8d18_34.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/compiler-rt-19.1.7-h855ad52_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ld64-956.6-llvm19_1_he86490a_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ld64_osx-arm64-956.6-llvm19_1_ha2625f7_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libclang-cpp19.1-19.1.7-default_hf3020a7_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libclang-cpp22.1-22.1.8-default_hdca5a3d_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.88.3-ha08bb59_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libllvm19-19.1.7-h8e0c9ce_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libllvm22-22.1.8-h89af1be_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsigtool-0.1.3-h98dc951_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1b79a29_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.3-h6967ea9_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-heed7d32_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lldb-22.1.8-py314h68e168f_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-22.1.8-hc7d1edf_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-19-19.1.7-h91fd4e7_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-19.1.7-h855ad52_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-h30297fc_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pkg-config-0.29.2-hde07d2e_1009.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-h156bc91_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.96.1-h4ff7c5d_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-analyzer-2026.04.27-h6fdd925_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sigtool-codesign-0.1.3-h98dc951_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tapi-1600.0.11.8-hb561403_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hd3d0363_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + docs: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils-2.46.1-default_h4852527_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.46.1-default_hfdba357_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_linux-64-2.46.1-default_h4852527_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/c-compiler-1.11.0-h4d9bdce_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/conda-gcc-specs-14.4.0-h611768e_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc-14.4.0-hc6a0c74_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-14.4.0-h9711763_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_linux-64-14.4.0-hc469d41_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-16.1.0-h69a702a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-14.4.0-hf39dbba_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.1.0-h934c35e_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pkg-config-0.29.2-h4bc722e_1009.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.96.1-h53717f1_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/typst-0.15.1-he64ecbb_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-14.4.0-hd9a9cd0_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-14.4.0-ha5b54cb_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.96.1-h2c6d0dc_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + osx-64: + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-64-19.1.7-h138dee1_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-apple-darwin-1.96.1-h38e4360_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sdkroot_env_osx-64-26.0-h62b880e_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/c-compiler-1.11.0-h7a00415_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/cctools-1030.6.3-llvm19_1_h67a6458_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/cctools_impl_osx-64-1030.6.3-llvm19_1_h6ae9dcd_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/cctools_osx-64-1030.6.3-llvm19_1_h67a6458_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/clang-19-19.1.7-default_h9399c5b_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/clang-19.1.7-default_h1323312_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/clang_impl_osx-64-19.1.7-default_ha1a018a_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/clang_osx-64-19.1.7-h8a78ed7_34.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/compiler-rt-19.1.7-he914875_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ld64-956.6-llvm19_1_hc3792c1_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ld64_osx-64-956.6-llvm19_1_hf4e8e46_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libclang-cpp19.1-19.1.7-default_h9399c5b_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-22.1.8-h19cb2f5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libiconv-1.18-h57a12c2_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libllvm19-19.1.7-h56e7563_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libsigtool-0.1.3-hc0f2934_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-16-2.15.3-h0d7f165_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-2.15.3-h0712280_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-openmp-22.1.8-h0d3cbff_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-tools-19-19.1.7-h879f4bc_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-tools-19.1.7-hb0207f0_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.3-hc881268_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pkg-config-0.29.2-hf7e621a_1009.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/rust-1.96.1-h5655b98_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/sigtool-codesign-0.1.3-hc0f2934_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/tapi-1600.0.11.8-h44950e2_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/typst-0.15.1-h19f9e61_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-arm64-19.1.7-he32a8d3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.96.1-hf6ec828_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sdkroot_env_osx-arm64-26.0-ha3f98da_7.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-compiler-1.11.0-h61f9b84_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools-1030.6.3-llvm19_1_hd01ab73_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools_impl_osx-arm64-1030.6.3-llvm19_1_hc7668c6_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools_osx-arm64-1030.6.3-llvm19_1_hd01ab73_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang-19-19.1.7-default_hf3020a7_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang-19.1.7-default_hf9bcbb7_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang_impl_osx-arm64-19.1.7-default_hc11f16d_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang_osx-arm64-19.1.7-h75f8d18_34.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/compiler-rt-19.1.7-h855ad52_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ld64-956.6-llvm19_1_he86490a_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ld64_osx-arm64-956.6-llvm19_1_ha2625f7_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libclang-cpp19.1-19.1.7-default_hf3020a7_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.88.3-ha08bb59_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libllvm19-19.1.7-h8e0c9ce_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsigtool-0.1.3-h98dc951_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.3-h6967ea9_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-heed7d32_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-22.1.8-hc7d1edf_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-19-19.1.7-h91fd4e7_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-19.1.7-h855ad52_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-h30297fc_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pkg-config-0.29.2-hde07d2e_1009.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.96.1-h4ff7c5d_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sigtool-codesign-0.1.3-h98dc951_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tapi-1600.0.11.8-hb561403_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/typst-0.15.1-h6fdd925_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + release: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils-2.46.1-default_h4852527_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.46.1-default_hfdba357_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_linux-64-2.46.1-default_h4852527_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/c-compiler-1.11.0-h4d9bdce_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/conda-gcc-specs-14.4.0-h611768e_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc-14.4.0-hc6a0c74_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-14.4.0-h9711763_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_linux-64-14.4.0-hc469d41_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-16.1.0-h69a702a_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-14.4.0-hf39dbba_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.1.0-h934c35e_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pkg-config-0.29.2-h4bc722e_1009.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pydantic-core-2.46.4-py314h2e6c369_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.96.1-h53717f1_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd70dff1_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.8.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bracex-3.0.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bump-my-version-1.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore2-2.5.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpx2-2.5.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-14.4.0-hd9a9cd0_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-14.4.0-ha5b54cb_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt_toolkit-3.0.53-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.13.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-settings-2.14.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/questionary-2.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-click-1.9.8-pyh8f84b5b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.96.1-h2c6d0dc_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomlkit-0.15.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/truststore-0.10.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wcmatch-11.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda + osx-64: + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.8.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bracex-3.0.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bump-my-version-1.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-64-19.1.7-h138dee1_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore2-2.5.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpx2-2.5.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt_toolkit-3.0.53-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.13.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-settings-2.14.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/questionary-2.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-click-1.9.8-pyh8f84b5b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-apple-darwin-1.96.1-h38e4360_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sdkroot_env_osx-64-26.0-h62b880e_7.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomlkit-0.15.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/truststore-0.10.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wcmatch-11.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h374f1ed_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/c-compiler-1.11.0-h7a00415_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/cctools-1030.6.3-llvm19_1_h67a6458_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/cctools_impl_osx-64-1030.6.3-llvm19_1_h6ae9dcd_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/cctools_osx-64-1030.6.3-llvm19_1_h67a6458_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/clang-19-19.1.7-default_h9399c5b_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/clang-19.1.7-default_h1323312_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/clang_impl_osx-64-19.1.7-default_ha1a018a_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/clang_osx-64-19.1.7-h8a78ed7_34.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/compiler-rt-19.1.7-he914875_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ld64-956.6-llvm19_1_hc3792c1_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ld64_osx-64-956.6-llvm19_1_hf4e8e46_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libclang-cpp19.1-19.1.7-default_h9399c5b_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-22.1.8-h19cb2f5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.8.1-hcc62823_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-hd1f9c09_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libiconv-1.18-h57a12c2_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libllvm19-19.1.7-h56e7563_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-hf3981d6_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libsigtool-0.1.3-h8c25ef7_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.53.4-h77d7759_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-16-2.15.3-h0d7f165_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-2.15.3-h0712280_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-openmp-22.1.8-h0d3cbff_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-tools-19-19.1.7-h879f4bc_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-tools-19.1.7-hb0207f0_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.3-hc881268_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pkg-config-0.29.2-hf7e621a_1009.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/pydantic-core-2.46.4-py314h8916c15_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.14.6-h7c6738f_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/rust-1.96.1-h5655b98_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/sigtool-codesign-0.1.3-h8c25ef7_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/tapi-1600.0.11.8-h44950e2_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-hb794df6_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.8.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bracex-3.0.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/bump-my-version-1.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-arm64-19.1.7-he32a8d3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore2-2.5.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpx2-2.5.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/prompt_toolkit-3.0.53-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.13.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-settings-2.14.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/questionary-2.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-click-1.9.8-pyh8f84b5b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.96.1-hf6ec828_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sdkroot_env_osx-arm64-26.0-ha3f98da_7.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomlkit-0.15.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/truststore-0.10.4-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhcf101f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wcmatch-11.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-compiler-1.11.0-h61f9b84_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools-1030.6.3-llvm19_1_hd01ab73_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools_impl_osx-arm64-1030.6.3-llvm19_1_hc7668c6_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools_osx-arm64-1030.6.3-llvm19_1_hd01ab73_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang-19-19.1.7-default_hf3020a7_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang-19.1.7-default_hf9bcbb7_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang_impl_osx-arm64-19.1.7-default_hc11f16d_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang_osx-arm64-19.1.7-h75f8d18_34.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/compiler-rt-19.1.7-h855ad52_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hc7cc350_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ld64-956.6-llvm19_1_he86490a_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ld64_osx-arm64-956.6-llvm19_1_ha2625f7_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libclang-cpp19.1-19.1.7-default_hf3020a7_9.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.88.3-ha08bb59_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-h493aca8_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libllvm19-19.1.7-h8e0c9ce_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsigtool-0.1.3-h98dc951_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.3-h5ef1a60_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-h5654f7c_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-22.1.8-hc7d1edf_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-19-19.1.7-h91fd4e7_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-19.1.7-h855ad52_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-h30297fc_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pkg-config-0.29.2-hde07d2e_1009.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pydantic-core-2.46.4-py314h54f3292_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-h156bc91_101_cp314.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.96.1-h4ff7c5d_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/sigtool-codesign-0.1.3-h98dc951_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tapi-1600.0.11.8-hb561403_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hd3d0363_3.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda +packages: +- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 + md5: a9f577daf3de00bca7c3c76c0ecbd1de + depends: + - __glibc >=2.17,<3.0.a0 + - libgomp >=7.5.0 + constrains: + - openmp_impl <0.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 28948 + timestamp: 1770939786096 +- conda: https://conda.anaconda.org/conda-forge/linux-64/binutils-2.46.1-default_h4852527_102.conda + sha256: 659c367ef49df7741749a2ad240b007d7df51b4f210505a78543deafb001e44c + md5: e8452fe381cac5fff20563a07722dfa5 + depends: + - binutils_impl_linux-64 >=2.46.1,<2.46.2.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 35399 + timestamp: 1784214547142 +- conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_impl_linux-64-2.46.1-default_hfdba357_102.conda + sha256: fb7bf36984a37ce7e4714d1d1da0bd0e3bfc679520f5cdc184afc676fd4b5da2 + md5: a0c5e0b7f58c8ceeb08e5bc41251d5a2 + depends: + - ld_impl_linux-64 2.46.1 default_hbd61a6d_102 + - sysroot_linux-64 + - zstd >=1.5.7,<1.6.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 3713752 + timestamp: 1784214522814 +- conda: https://conda.anaconda.org/conda-forge/linux-64/binutils_linux-64-2.46.1-default_h4852527_102.conda + sha256: 08d7238663fc408ba2ab60b02fa3d06a7ca9d872962e03e90c7e0fdecb7ed1d0 + md5: 32fd07abe84eb14f17c7f5cc6fa8df82 + depends: + - binutils_impl_linux-64 2.46.1 default_hfdba357_102 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 36337 + timestamp: 1784214551894 +- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda + sha256: 1a0d382c515ebf55f8ee1f38c8b81bc95af5c2acc42ad53b66bc5df932032f96 + md5: e675fabcf81499adc7edf58124fb1e01 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 257808 + timestamp: 1785906269155 +- conda: https://conda.anaconda.org/conda-forge/linux-64/c-compiler-1.11.0-h4d9bdce_0.conda + sha256: 8e7a40f16400d7839c82581410aa05c1f8324a693c9d50079f8c50dc9fb241f0 + md5: abd85120de1187b0d1ec305c2173c71b + depends: + - binutils + - gcc + - gcc_linux-64 14.* + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 6693 + timestamp: 1753098721814 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cargo-nextest-0.9.143-hb17b654_0.conda + sha256: a71ed638bb60cbfd17a2259b878a8b5c27c83e0e84b01f67cc57653ee752559a + md5: 999f1e1b5cc106f3f9fc452492567849 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - __glibc >=2.17 + license: MIT + run_exports: {} + size: 7120019 + timestamp: 1785902359435 +- conda: https://conda.anaconda.org/conda-forge/linux-64/conda-gcc-specs-14.4.0-h611768e_1.conda + sha256: 81327f9b1db24d4d6d2326a6dc21a799a427d0fd8b3a949881a939b3ce44cfc8 + md5: efd6d10c5ffe7431aca2fd8c78c60c32 + depends: + - gcc_impl_linux-64 >=14.4.0,<14.4.1.0a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 32239 + timestamp: 1785374690936 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gcc-14.4.0-hc6a0c74_1.conda + sha256: e8bedab429b82df70562525aeaaab9c256a8713aecd4fab696310807e10a7460 + md5: fcd2b41f019953fb6e89efaef2905ce2 + depends: + - conda-gcc-specs + - gcc_impl_linux-64 14.4.0 h9711763_1 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 29190 + timestamp: 1785374790020 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_impl_linux-64-14.4.0-h9711763_1.conda + sha256: 7945f55ef3cbd81132dd42258295c906ef5b8df20936f6d0b9bdaa741a0e03c7 + md5: ccaabac23b7229f71df5aa354121c41f + depends: + - binutils_impl_linux-64 >=2.46.1 + - libgcc >=14.4.0 + - libgcc-devel_linux-64 14.4.0 hd9a9cd0_101 + - libgomp >=14.4.0 + - libsanitizer 14.4.0 hf39dbba_1 + - libstdcxx >=14.4.0 + - libstdcxx-devel_linux-64 14.4.0 ha5b54cb_101 + - sysroot_linux-64 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 78573599 + timestamp: 1785374597260 +- conda: https://conda.anaconda.org/conda-forge/linux-64/gcc_linux-64-14.4.0-hc469d41_0.conda + sha256: f529b3afdb6ed570618387d363c79cf5f1b2e638e76a5fa11b93e64b6b6d6d06 + md5: e5775fadf3bde7e53d0750c540586afa + depends: + - gcc_impl_linux-64 14.4.0.* + - binutils_linux-64 + - sysroot_linux-64 + license: BSD-3-Clause + license_family: BSD + run_exports: + strong: + - libgcc >=14 + size: 29731 + timestamp: 1785386556095 +- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h54a6638_2.conda + sha256: d7c260b7e1cf22ce04d6ba8a86eabf4e6c50bc96a5c27fe2ecb32298af3e88eb + md5: 4ef4b977bb216a3001a3334696a80850 + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: MIT + license_family: MIT + run_exports: + weak: + - icu >=78.3,<79.0a0 + size: 14455340 + timestamp: 1784916378180 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_102.conda + sha256: 27d83f1188cd19bcb7754a078b3fa7f4cfb8527f8eb2fde54dd01fc529d1adec + md5: 449500f2c089da11c40f5c21312e3e07 + depends: + - __glibc >=2.17,<3.0.a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-64 2.46.1 + license: GPL-3.0-only + license_family: GPL + run_exports: {} + size: 745303 + timestamp: 1784214507189 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libclang-cpp22.1-22.1.8-default_h6c227bf_3.conda + sha256: ccb8bd0a8f2d57675b6a60dabb0cc8becb35c2748ac4ec920d7f7625b93c16d8 + md5: 864e6d29ec7378b89ff5b5c9c629099e + depends: + - libstdcxx >=14 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - libllvm22 >=22.1.8,<22.2.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: + weak: + - libclang-cpp22.1 >=22.1.8,<22.2.0a0 + size: 24175081 + timestamp: 1782358841320 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda + sha256: d789471216e7aba3c184cd054ed61ce3f6dac6f87a50ec69291b9297f8c18724 + md5: c277e0a4d549b03ac1e9d6cbbe3d017b + depends: + - ncurses + - __glibc >=2.17,<3.0.a0 + - libgcc >=13 + - ncurses >=6.5,<7.0a0 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - libedit >=3.1.20250104,<3.2.0a0 + size: 134676 + timestamp: 1738479519902 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + sha256: 16feffd9ddbbe5b718515d38ee376c685ba95491cd901244e24671d20b952a77 + md5: b24d3c612f71e7aa74158d92106318b2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + run_exports: {} + size: 77856 + timestamp: 1781203599810 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6 + md5: a360c33a5abe61c07959e449fa1453eb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + run_exports: + weak: + - libffi >=3.5.2,<3.6.0a0 + size: 58592 + timestamp: 1769456073053 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.1.0-ha9f2e26_1.conda + sha256: d5cb8475131c31680f8fd30512c418f373064e272e452063276a8fb14c9fa42f + md5: 5a7d954665c707c93311657cd779c705 + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + constrains: + - libgomp 16.1.0 he0feb66_1 + - libgcc-ng ==16.1.0=*_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 1057877 + timestamp: 1785375436766 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-16.1.0-h69a702a_1.conda + sha256: 225275c562337a1cd61705da0ee4235dde7bba7504de1c34b74c894adb2b0eee + md5: 7ed870c014a6f23c7dfafda53d2763a9 + depends: + - libgcc 16.1.0 ha9f2e26_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - libgcc + size: 28210 + timestamp: 1785375440733 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.1.0-he0feb66_1.conda + sha256: 62cb599ad0539d99386515326d9d5e8f51f75a60c69c2131b21df76edf35bd89 + md5: 88f2d91cb1533194c323534253094d23 + depends: + - __glibc >=2.17,<3.0.a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + strong: + - _openmp_mutex >=4.5 + size: 640415 + timestamp: 1785375373755 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda + sha256: c467851a7312765447155e071752d7bf9bf44d610a5687e32706f480aad2833f + md5: 915f5995e94f60e9a4826e0b0920ee88 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: LGPL-2.1-only + run_exports: + weak: + - libiconv >=1.18,<2.0a0 + size: 790176 + timestamp: 1754908768807 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libllvm22-22.1.8-hf7376ad_1.conda + sha256: e9b5f301d6b001a9b8ce782157f56b75c92c4fbc9eba95dc6345c1139251d13b + md5: 298bb2483fc7d15396147cf1c1465359 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: + weak: + - libllvm22 >=22.1.8,<22.2.0a0 + size: 44320272 + timestamp: 1781788728739 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + sha256: ec30e52a3c1bf7d0425380a189d209a52baa03f22fb66dd3eb587acaa765bd6d + md5: b88d90cad08e6bc8ad540cb310a761fb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - xz 5.8.3.* + license: 0BSD + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 113478 + timestamp: 1775825492909 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + sha256: fe171ed5cf5959993d43ff72de7596e8ac2853e9021dec0344e583734f1e0843 + md5: 2c21e66f50753a083cbe6b80f38268fa + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 92400 + timestamp: 1769482286018 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsanitizer-14.4.0-hf39dbba_1.conda + sha256: 8e58b5e35c2d75d4f6a01d5650c5d672b8a96608a59af3a261050ccefe33702b + md5: 07c5c310a267103ab4dac904a933a4a4 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14.4.0 + - libstdcxx >=14.4.0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: + weak: + - libsanitizer 14.4.0 + size: 7523825 + timestamp: 1785374558337 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.4-hf4e2dac_0.conda + sha256: 72023efc207fe681e26b65fc9d668062cf0b4f0eacf3431e6eb099b95c1f2efd + md5: df088a279cd5e6fd2790b4c196434da1 + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + license: blessing + run_exports: + weak: + - libsqlite >=3.53.4,<4.0a0 + size: 964200 + timestamp: 1785016112246 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-16.1.0-h934c35e_1.conda + sha256: 79721dd08aeb0ab9e773f1f9ef41cf4e6c17477e3d72319147619045bce05a09 + md5: aed6cf89adc1e9b846e4367ac538e434 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc 16.1.0 ha9f2e26_1 + constrains: + - libstdcxx-ng ==16.1.0=*_1 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 6631744 + timestamp: 1785375462643 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + sha256: 9b1bdce27a7e31f7d241aeecff67a1f3101d52a2b1e33ccc2cdf2613072bf81f + md5: 01bb81d12c957de066ea7362007df642 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - libuuid >=2.42.2,<3.0a0 + size: 40017 + timestamp: 1781625522462 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda + sha256: 3d44f737c5ae52d5af32682cc1530df433f401f8e58a7533926536244127572a + md5: e79d2c2f24b027aa8d5ab1b1ba3061e7 + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - libxml2 2.15.3 + license: MIT + license_family: MIT + run_exports: {} + size: 559775 + timestamp: 1776376739004 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.conda + sha256: 3bc5551720c58591f6ea1146f7d1539c734ed1c40e7b9f5cb8cb7e900c509aba + md5: 995d8c8bad2a3cc8db14675a153dec2b + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + - libgcc >=14 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2-16 2.15.3 hca6bf5a_0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libxml2 + - libxml2-16 >=2.15.3 + size: 46810 + timestamp: 1776376751152 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda + sha256: eb8a0db0aa570124f7d2a93d7c7f596e3390df5e047818d873baad32985fc736 + md5: 0de0122d9570a8ab637c6b73db268389 + depends: + - __glibc >=2.17,<3.0.a0 + constrains: + - zlib 1.3.2 *_3 + license: Zlib + license_family: Other + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 63713 + timestamp: 1785362952714 +- conda: https://conda.anaconda.org/conda-forge/linux-64/lldb-22.1.8-py314ha98bd89_0.conda + sha256: a7197e6b5857617614168d63d6fb24e9272d462eed2fb3b3919d27545971a5fe + md5: 3c28e43d96681be923b4e9876ad114d1 + depends: + - __glibc >=2.17,<3.0.a0 + - libclang-cpp22.1 >=22.1.8,<22.2.0a0 + - libedit >=3.1.20250104,<3.2.0a0 + - libgcc >=14 + - libllvm22 >=22.1.8,<22.2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libstdcxx >=14 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - six + - zstd >=1.5.7,<1.6.0a0 + constrains: + - llvmdev ==22.1.8 + - clangdev ==22.1.8 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 9870955 + timestamp: 1784665546829 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + sha256: fc89f74bbe362fb29fa3c037697a89bec140b346a2469a90f7936d1d7ea4d8a3 + md5: fc21868a1a5aacc937e7a18747acb8a5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: X11 AND BSD-3-Clause + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 918956 + timestamp: 1777422145199 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_1.conda + sha256: 012096056b97abf1f68c46b7146bd2cbd68c1be762340b4f5dad4fbbe99177bc + md5: c5955c27917ff2234def47f075e71e02 + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 3182423 + timestamp: 1785913583650 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pkg-config-0.29.2-h4bc722e_1009.conda + sha256: c9601efb1af5391317e04eca77c6fe4d716bf1ca1ad8da2a05d15cb7c28d7d4e + md5: 1bee70681f504ea424fb07cdb090c001 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc-ng >=12 + license: GPL-2.0-or-later + license_family: GPL + run_exports: {} + size: 115175 + timestamp: 1720805894943 +- conda: https://conda.anaconda.org/conda-forge/linux-64/pydantic-core-2.46.4-py314h2e6c369_0.conda + sha256: 802e216c39f1359aed60823b6e11d8ccd812b0ae1c81ae5ac1c81f99446409ab + md5: 0c96993dbeadf3a277cf757b9f1c9412 + depends: + - python + - typing-extensions >=4.6.0,!=4.7.0 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - python_abi 3.14.* *_cp314 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + run_exports: {} + size: 1895020 + timestamp: 1778084229247 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.6-habeac84_101_cp314.conda + build_number: 101 + sha256: ee8f2006e1724b1f2e9e0ccc5a7cfdcab973460faa2f63ac1f6e44fdad4c0344 + md5: 78975a41cf3c525da654f17e35bfca9e + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.3,<4.0a0 + - libuuid >=2.42.2,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + run_exports: + weak: + - python_abi 3.14.* *_cp314 + noarch: + - python + size: 36869055 + timestamp: 1784910110714 + python_site_packages_path: lib/python3.14/site-packages +- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 + md5: d7d95fc8287ea7bf33e0e7116d2b95ec + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: + weak: + - readline >=8.3,<9.0a0 + size: 345073 + timestamp: 1765813471974 +- conda: https://conda.anaconda.org/conda-forge/linux-64/rust-1.96.1-h53717f1_2.conda + sha256: bcd57a7688025fa9b41d069aacd6bb105bb58ea606661d39cb6f1a0aa2f2e027 + md5: 430f3cd645254449f1fb5461ffaf7c04 + depends: + - __glibc >=2.17,<3.0.a0 + - gcc_impl_linux-64 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + - rust-std-x86_64-unknown-linux-gnu 1.96.1 h2c6d0dc_2 + - sysroot_linux-64 >=2.17 + license: MIT + license_family: MIT + run_exports: + strong_constrains: + - __glibc >=2.17 + size: 172400900 + timestamp: 1783677925238 +- conda: https://conda.anaconda.org/conda-forge/linux-64/rust-analyzer-2026.04.27-hb17b654_0.conda + sha256: 6efe24ffd920871f1350b9846e4656d7be272c13ab0d5a4d0946c4fa4823e0a8 + md5: 2917629a16ee42d265c55acadb55cb8c + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + constrains: + - __glibc >=2.17 + license: MIT OR Apache-2.0 + run_exports: {} + size: 11755985 + timestamp: 1777398119265 +- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd70dff1_3.conda + build_number: 103 + sha256: 43624eab22f5f29df7d6ffe914cf442f28fd559b55b290906255492826e636e8 + md5: 48a1049e710857572fc2a832aa394d9f + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + constrains: + - xorg-libx11 >=1.8.13,<2.0a0 + license: TCL + run_exports: + weak: + - tk >=8.6.13,<8.7.0a0 + size: 3550916 + timestamp: 1784229071544 +- conda: https://conda.anaconda.org/conda-forge/linux-64/typst-0.15.1-he64ecbb_0.conda + sha256: f79893b2704c6d9438103259bd1332b95947f2cdc43dcdda8869d80b545f34c4 + md5: a5d32c4c416ae858752562d7a6c6fcd3 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - openssl >=3.5.7,<4.0a0 + constrains: + - __glibc >=2.17 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 18555519 + timestamp: 1784301238746 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 + md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 + depends: + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - zstd >=1.5.7,<1.6.0a0 + size: 601375 + timestamp: 1764777111296 +- conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.8.0-pyhd8ed1ab_0.conda + sha256: b8fcb994134d3918d1c64a9d62f4168ff85d5bfb89e698102fe4ed679fe0df24 + md5: 108c928d2a8551832dbc762b535e90bb + depends: + - python >=3.10 + - typing-extensions >=4.0.0 + license: MIT + license_family: MIT + run_exports: {} + size: 19461 + timestamp: 1784935220549 +- conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.2-pyhcf101f3_0.conda + sha256: e36998c5e860e26b22e5dbcd5726dd0c4eabad949c84d383cad8512757bbf6a1 + md5: fb568fbae6908ba86a090a85a089d11f + depends: + - exceptiongroup >=1.0.2 + - idna >=2.8 + - python >=3.10 + - typing_extensions >=4.5 + - python + constrains: + - trio >=0.32.0 + - uvloop >=0.22.1 + - winloop >=0.2.3 + license: MIT + license_family: MIT + run_exports: {} + size: 164465 + timestamp: 1783889660383 +- conda: https://conda.anaconda.org/conda-forge/noarch/bracex-3.0.1-pyhcf101f3_0.conda + sha256: 958861098a6985d27eb72579adf0866b3dc99076b5e21362aeb02e4773cb5bc1 + md5: 97f749ec797659e8338561149df0b76b + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 18298 + timestamp: 1784591912561 +- conda: https://conda.anaconda.org/conda-forge/noarch/bump-my-version-1.5.0-pyhd8ed1ab_0.conda + sha256: 961fadf4bf69ff62437e6032618c71b3c29a7565ec60c6741668d977cb923c96 + md5: b7c38694ce6e74e390565c23f4231ef7 + depends: + - click <8.4 + - httpx2 + - pydantic >=2.0.0 + - pydantic-settings + - python >=3.10 + - questionary + - rich + - rich-click + - tomlkit + - wcmatch >=8.5.1 + license: MIT + license_family: MIT + run_exports: {} + size: 54116 + timestamp: 1785233212974 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda + sha256: 0a0544cf95f64394fe4959286f5c71f5444ad58feb0602e53becb27448d24da6 + md5: 0f51e2391ade309db462a55611263e9c + depends: + - __unix + license: ISC + run_exports: {} + size: 131780 + timestamp: 1784754889428 +- conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.3-pyhc90fa1f_0.conda + sha256: 37a5d8b10ea3516e2c42f870c9c351b9f7b31ff48c66d83490039f417e1e5228 + md5: 2266262ce8a425ecb6523d765f79b303 + depends: + - __unix + - python + - python >=3.10 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 100048 + timestamp: 1777219902525 +- conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-64-19.1.7-h138dee1_1.conda + sha256: e6effe89523fc6143819f7a68372b28bf0c176af5b050fe6cf75b62e9f6c6157 + md5: 32deecb68e11352deaa3235b709ddab2 + depends: + - clang 19.1.7.* + constrains: + - compiler-rt 19.1.7 + - clangxx 19.1.7 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 10425780 + timestamp: 1757412396490 +- conda: https://conda.anaconda.org/conda-forge/noarch/compiler-rt_osx-arm64-19.1.7-he32a8d3_1.conda + sha256: 8c32a3db8adf18ed58197e8895ce4f24a83ed63c817512b9a26724753b116f2a + md5: 8d99c82e0f5fed6cc36fcf66a11e03f0 + depends: + - clang 19.1.7.* + constrains: + - compiler-rt 19.1.7 + - clangxx 19.1.7 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 10490535 + timestamp: 1757411851093 +- conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda + sha256: ee6cf346d017d954255bbcbdb424cddea4d14e4ed7e9813e429db1d795d01144 + md5: 8e662bd460bda79b1ea39194e3c4c9ab + depends: + - python >=3.10 + - typing_extensions >=4.6.0 + license: MIT and PSF-2.0 + run_exports: {} + size: 21333 + timestamp: 1763918099466 +- conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda + sha256: 96cac6573fd35ae151f4d6979bab6fbc90cb6b1fb99054ba19eb075da9822fcb + md5: b8993c19b0c32a2f7b66cbb58ca27069 + depends: + - python >=3.10 + - typing_extensions + - python + license: MIT + license_family: MIT + run_exports: {} + size: 39069 + timestamp: 1767729720872 +- conda: https://conda.anaconda.org/conda-forge/noarch/httpcore2-2.5.0-pyhcf101f3_0.conda + sha256: 24f4cda7df09d9f5e79bf1eaa47cfe0cf379beef13788f4cbb32bb9c29076f7a + md5: d58db3677b5352fe18bf36254b350c16 + depends: + - h11 >=0.16 + - python >=3.10 + - truststore >=0.10 + - python + constrains: + - anyio >=4.5.0,<5.0 + - h2 >=3,<5 + - socksio 1.* + - trio >=0.22.0,<1.0 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 52749 + timestamp: 1782411845881 +- conda: https://conda.anaconda.org/conda-forge/noarch/httpx2-2.5.0-pyhcf101f3_0.conda + sha256: 458d054e9641c8f5f792074bc85ce70ebc0928d1973c2c57ffb87c3c060cc62e + md5: 85b059d837508ef81872a293a563dd1a + depends: + - anyio + - httpcore2 ==2.5.0 + - idna >=3.18 + - python >=3.10 + - truststore >=0.10 + - typing_extensions >=4.5.0 + - python + constrains: + - click 8.* + - pygments 2.* + - rich >=10,<16 + - h2 >=3,<5 + - socksio 1.* + - zstandard >=0.18.0 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 72841 + timestamp: 1782420230376 +- conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda + sha256: c75632ea624aa450a394f570749420c5a2e0997d0216bc29d5d45b0f39df0426 + md5: 577b04680ae422adb86fc60d7b940659 + depends: + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 163869 + timestamp: 1781620148226 +- conda: https://conda.anaconda.org/conda-forge/noarch/kernel-headers_linux-64-4.18.0-he073ed8_9.conda + sha256: 41557eeadf641de6aeae49486cef30d02a6912d8da98585d687894afd65b356a + md5: 86d9cba083cd041bfbf242a01a7a1999 + constrains: + - sysroot_linux-64 ==2.28 + license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later + license_family: GPL + run_exports: {} + size: 1278712 + timestamp: 1765578681495 +- conda: https://conda.anaconda.org/conda-forge/noarch/libgcc-devel_linux-64-14.4.0-hd9a9cd0_101.conda + sha256: dd2fd8417476a4086dc5a5ac64a884cf953c7799ac329ddea9da36f056008110 + md5: 8bd1e927275c36238cc26cc9181fd191 + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 3084295 + timestamp: 1785374455100 +- conda: https://conda.anaconda.org/conda-forge/noarch/libstdcxx-devel_linux-64-14.4.0-ha5b54cb_101.conda + sha256: 726c006d8f078a68b15d715b64e8c72a4b10907fa1a812dfcaef9ed888c03480 + md5: 774c4627558281a7f6f7ae7b39d6fac1 + depends: + - __unix + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + run_exports: {} + size: 19516469 + timestamp: 1785374476067 +- conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda + sha256: 0c4c35376fe920714390d46e4b8d31c876d65f18e1655899e0763ec25f2a902f + md5: 6d03368f2b2b0a5fb6839df53b2eb5e0 + depends: + - mdurl >=0.1,<1 + - python >=3.10 + license: MIT + license_family: MIT + run_exports: {} + size: 69017 + timestamp: 1778169663339 +- conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda + sha256: 78c1bbe1723449c52b7a9df1af2ee5f005209f67e40b6e1d3c7619127c43b1c7 + md5: 592132998493b3ff25fd7479396e8351 + depends: + - python >=3.9 + license: MIT + license_family: MIT + run_exports: {} + size: 14465 + timestamp: 1733255681319 +- conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.53-pyha770c72_0.conda + sha256: efe8def2c93aa34cd8d3c9af1dc4c7d312791cf769d8b2b615e32733e6df6051 + md5: 39c92a39517316e5001d645ae63d9ab9 + depends: + - python >=3.10 + - wcwidth + constrains: + - prompt_toolkit 3.0.53 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 276081 + timestamp: 1785160613307 +- conda: https://conda.anaconda.org/conda-forge/noarch/prompt_toolkit-3.0.53-hd8ed1ab_0.conda + sha256: 59628c765189e99ca5d3c51f0758a325bc020dfafb8fef6068045595aaae1baf + md5: 4c7171dde29a2f2b1dac681c1154a291 + depends: + - prompt-toolkit >=3.0.53,<3.0.54.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 7083 + timestamp: 1785160614678 +- conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.13.4-pyhcf101f3_0.conda + sha256: 69700e31165df070e9716315e042196aa92525dae5deb5107785847ab9f4189f + md5: 729843edafc0899b3348bd3f19525b9d + depends: + - typing-inspection >=0.4.2 + - typing_extensions >=4.14.1 + - python >=3.10 + - annotated-types >=0.6.0 + - pydantic-core ==2.46.4 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 346511 + timestamp: 1778103405862 +- conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-settings-2.14.2-pyhcf101f3_0.conda + sha256: de1eb2cdb22a678387ec7757f10b7815c4c2a4d62f23b2a70fe4beff7e8a1fd4 + md5: a1c5305893d9956abd2079d377c5113f + depends: + - typing-inspection >=0.4.0 + - python >=3.10 + - pydantic >=2.7.0 + - python-dotenv >=0.21.0 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 52920 + timestamp: 1781884990751 +- conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda + sha256: cf70b2f5ad9ae472b71235e5c8a736c9316df3705746de419b59d442e8348e86 + md5: 16c18772b340887160c79a6acc022db0 + depends: + - python >=3.10 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 893031 + timestamp: 1774796815820 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.2-pyhcf101f3_0.conda + sha256: 74e417a768f59f02a242c25e7db0aa796627b5bc8c818863b57786072aeb85e5 + md5: 130584ad9f3a513cdd71b1fdc1244e9c + depends: + - python >=3.10 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 27848 + timestamp: 1772388605021 +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda + build_number: 8 + sha256: ad6d2e9ac39751cc0529dd1566a26751a0bf2542adb0c232533d32e176e21db5 + md5: 0539938c55b6b1a59b560e843ad864a4 + constrains: + - python 3.14.* *_cp314 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 6989 + timestamp: 1752805904792 +- conda: https://conda.anaconda.org/conda-forge/noarch/questionary-2.1.1-pyhd8ed1ab_0.conda + sha256: 0604c6dff3af5f53e34fceb985395d08287137f220450108a795bcd1959caf14 + md5: 34fa231b5c5927684b03bb296bb94ddc + depends: + - prompt_toolkit >=2.0,<4.0 + - python >=3.10 + license: MIT + license_family: MIT + run_exports: {} + size: 31257 + timestamp: 1757356458097 +- conda: https://conda.anaconda.org/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda + sha256: 3d6ba2c0fcdac3196ba2f0615b4104e532525ffa1335b50a2878be5ff488814a + md5: 0242025a3c804966bf71aa04eee82f66 + depends: + - markdown-it-py >=2.2.0 + - pygments >=2.13.0,<3.0.0 + - python >=3.10 + - typing_extensions >=4.0.0,<5.0.0 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 208577 + timestamp: 1775991661559 +- conda: https://conda.anaconda.org/conda-forge/noarch/rich-click-1.9.8-pyh8f84b5b_0.conda + sha256: 771b335400554d812dcdf7e40e54e47247db1f96ca0ab4413d061e6960844d9c + md5: 3251fe7203751993d1ee7ecb492b4a42 + depends: + - python >=3.10 + - rich >=12 + - click >=8 + - typing-extensions >=4 + - __unix + - python + license: MIT + license_family: MIT + run_exports: {} + size: 64412 + timestamp: 1780016631584 +- conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-aarch64-apple-darwin-1.96.1-hf6ec828_2.conda + sha256: 1360d8d01a301379415b37a8ffc78f755302af3ee5603ba0546e4db82673af02 + md5: 11b9425f6987f6fc387d98798befc850 + depends: + - __unix + constrains: + - rust >=1.96.1,<1.96.2.0a0 + license: MIT + license_family: MIT + run_exports: {} + size: 32659246 + timestamp: 1783676763305 +- conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-apple-darwin-1.96.1-h38e4360_2.conda + sha256: 68b051da2e66596a69865c7014da432a31ac91a6756a9f0655c169ccbc30989a + md5: 446b8d730ee45666c6cdbb19d84c9b45 + depends: + - __unix + constrains: + - rust >=1.96.1,<1.96.2.0a0 + license: MIT + license_family: MIT + run_exports: {} + size: 34282907 + timestamp: 1783676974195 +- conda: https://conda.anaconda.org/conda-forge/noarch/rust-std-x86_64-unknown-linux-gnu-1.96.1-h2c6d0dc_2.conda + sha256: ee5bd60a9af9d2ce8e006903a8109b05d6b33433cca9cfd90c702df516afc896 + md5: 72b791c3bec617cd86d61158b2326ab5 + depends: + - __unix + constrains: + - rust >=1.96.1,<1.96.2.0a0 + license: MIT + license_family: MIT + run_exports: {} + size: 37125652 + timestamp: 1783677781772 +- conda: https://conda.anaconda.org/conda-forge/noarch/sdkroot_env_osx-64-26.0-h62b880e_7.conda + sha256: 7e7e2556978bc9bd9628c6e39138c684082320014d708fbca0c9050df98c0968 + md5: 68a978f77c0ba6ca10ce55e188a21857 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 4948 + timestamp: 1771434185960 +- conda: https://conda.anaconda.org/conda-forge/noarch/sdkroot_env_osx-arm64-26.0-ha3f98da_7.conda + sha256: fabfe031ede99898cb2b0b805f6c0d64fcc24ecdb444de3a83002d8135bf4804 + md5: 5f0ebbfea12d8e5bddff157e271fdb2f + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 4971 + timestamp: 1771434195389 +- conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda + sha256: 458227f759d5e3fcec5d9b7acce54e10c9e1f4f4b7ec978f3bfd54ce4ee9853d + md5: 3339e3b65d58accf4ca4fb8748ab16b3 + depends: + - python >=3.9 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 18455 + timestamp: 1753199211006 +- conda: https://conda.anaconda.org/conda-forge/noarch/sysroot_linux-64-2.28-h4ee821c_9.conda + sha256: c47299fe37aebb0fcf674b3be588e67e4afb86225be4b0d452c7eb75c086b851 + md5: 13dc3adbc692664cd3beabd216434749 + depends: + - __glibc >=2.28 + - kernel-headers_linux-64 4.18.0 he073ed8_9 + - tzdata + license: LGPL-2.0-or-later AND LGPL-2.0-or-later WITH exceptions AND GPL-2.0-or-later + license_family: GPL + run_exports: + strong: + - __glibc >=2.28,<3.0.a0 + size: 24008591 + timestamp: 1765578833462 +- conda: https://conda.anaconda.org/conda-forge/noarch/tomlkit-0.15.1-pyhcf101f3_0.conda + sha256: 5cf833b4d199688b7652fb322ecc134de9555e9444d9249750de9a153421ad0a + md5: e4942e2de7436ff28be7f5645cf3c8d6 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 49054 + timestamp: 1784287707171 +- conda: https://conda.anaconda.org/conda-forge/noarch/truststore-0.10.4-pyhcf101f3_0.conda + sha256: eece5be81588c39a855a0b70da84e0febb878a6d91dd27d6d21370ce9e5c5a46 + md5: c2db35b004913ec69bcac64fb0783de0 + depends: + - python >=3.10,<4 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 24279 + timestamp: 1766494826559 +- conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda + sha256: b141933ece3518f6d7b75dfb59451e2f26b405a44c18e2518a83e9a02e09315c + md5: c680b5747e8c4c8f23dca0bb7042a8fc + depends: + - typing_extensions ==4.16.0 pyhcf101f3_0 + license: PSF-2.0 + license_family: PSF + run_exports: {} + size: 94080 + timestamp: 1783002732887 +- conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhcf101f3_2.conda + sha256: 8b90d2f19f9458b8c58a55e1fcdc1d90c1603a847a47654d8a454549413ba60a + md5: 53f5409c5cfd6c5a66417d68e3f0a864 + depends: + - python >=3.10 + - typing_extensions >=4.12.0 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 20935 + timestamp: 1777105465795 +- conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda + sha256: 2d888f90af0686044882c74193ec80a90ec1943145d94a7b1b048958acda1848 + md5: c70ad746c22219b9700931707482992c + depends: + - python >=3.10 + - python + license: PSF-2.0 + license_family: PSF + run_exports: {} + size: 52631 + timestamp: 1783002732887 +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda + sha256: b928c30ddcb0e3f544c6eade8352737e6e610e263276b90232db6a578ef899d8 + md5: fcb489df604d100968b737f2cb6076c6 + license: LicenseRef-Public-Domain + run_exports: {} + size: 118849 + timestamp: 1784250406640 +- conda: https://conda.anaconda.org/conda-forge/noarch/wcmatch-11.0-pyhcf101f3_0.conda + sha256: e77239d6a599be8b70b08fb06d1eb754288ad219a68bb28cfd3a92d226389243 + md5: c44743c4f4eb35b3078623bdff134af2 + depends: + - python >=3.10 + - bracex >=3.0 + - python + license: MIT + license_family: MIT + run_exports: {} + size: 42758 + timestamp: 1783680391789 +- conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda + sha256: 4acf845da404e84cef1acccc66cc0156af1b83a5b5d7077b2ca19b705c561e57 + md5: 99f7755ec8648a042b0dbe906234f888 + depends: + - python >=3.10 + license: MIT + license_family: MIT + run_exports: {} + size: 132415 + timestamp: 1782771807703 +- conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h374f1ed_10.conda + sha256: 4ed83961876dc8844a6f0df49c07b408efbaea275ffb0b37133e24c006990b3a + md5: 9d9a39212a876e4bb751c1cc3927b678 + depends: + - __osx >=11.0 + license: bzip2-1.0.6 + license_family: BSD + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 133271 + timestamp: 1785906721507 +- conda: https://conda.anaconda.org/conda-forge/osx-64/c-compiler-1.11.0-h7a00415_0.conda + sha256: 2bd1cf3d26789b7e1d04e914ccd169bd618fceed68abf7b6a305266b88dcf861 + md5: 2b23ec416cef348192a5a17737ddee60 + depends: + - cctools >=949.0.1 + - clang_osx-64 19.* + - ld64 >=530 + - llvm-openmp + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 6695 + timestamp: 1753098825695 +- conda: https://conda.anaconda.org/conda-forge/osx-64/cargo-nextest-0.9.143-h19f9e61_0.conda + sha256: 701e186526d0d651a723fa3f8faaa982363ca9f794b497fa78e08298a6cb2d44 + md5: 0848be9899d7ff881ef61de9443a6087 + depends: + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: MIT + run_exports: {} + size: 6985066 + timestamp: 1785902491652 +- conda: https://conda.anaconda.org/conda-forge/osx-64/cctools-1030.6.3-llvm19_1_h67a6458_5.conda + sha256: c4caba5a98fa45f19b275a2050dd957e87b5c0f37566aeec4a6ac82f7d259364 + md5: 604cefa2e7c4ede3e711c6d860c0d2b4 + depends: + - cctools_impl_osx-64 1030.6.3 llvm19_1_h6ae9dcd_5 + - ld64 956.6 llvm19_1_hc3792c1_5 + - libllvm19 >=19.1.7,<19.2.0a0 + license: APSL-2.0 + license_family: Other + run_exports: {} + size: 24445 + timestamp: 1785271414008 +- conda: https://conda.anaconda.org/conda-forge/osx-64/cctools_impl_osx-64-1030.6.3-llvm19_1_h6ae9dcd_5.conda + sha256: b2668b7985e4d4b5120142d48d9d6b3f61cf113bde72515d69b103dfec3ac582 + md5: 00aaea0167ed659ade53f685dfe487d1 + depends: + - __osx >=11.0 + - ld64_osx-64 >=956.6,<956.7.0a0 + - libcxx + - libllvm19 >=19.1.7,<19.2.0a0 + - libzlib >=1.3.2,<2.0a0 + - llvm-tools 19.1.* + - sigtool-codesign + constrains: + - ld64 956.6.* + - cctools 1030.6.3.* + - clang 19.1.* + license: APSL-2.0 + license_family: Other + run_exports: {} + size: 746510 + timestamp: 1785271380387 +- conda: https://conda.anaconda.org/conda-forge/osx-64/cctools_osx-64-1030.6.3-llvm19_1_h67a6458_5.conda + sha256: a20497a3679ed354786e4e869d50b6e7abac28e49e51cd576575c3e4a2702f84 + md5: d5b54a2fdebb1367256de3b341f7e29a + depends: + - cctools_impl_osx-64 1030.6.3 llvm19_1_h6ae9dcd_5 + - ld64_osx-64 956.6 llvm19_1_hf4e8e46_5 + constrains: + - cctools 1030.6.3.* + license: APSL-2.0 + license_family: Other + run_exports: {} + size: 23636 + timestamp: 1785271419893 +- conda: https://conda.anaconda.org/conda-forge/osx-64/clang-19-19.1.7-default_h9399c5b_9.conda + sha256: bdc69de3f6fdf17c4a86b5bdf2072ac7baf9b69734ee2f573822b8c46fe64b39 + md5: 664c48272c72fb25f3b6e1031ebc6a3f + depends: + - __osx >=11.0 + - libclang-cpp19.1 19.1.7 default_h9399c5b_9 + - libcxx >=19.1.7 + - libllvm19 >=19.1.7,<19.2.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 770717 + timestamp: 1776984724776 +- conda: https://conda.anaconda.org/conda-forge/osx-64/clang-19.1.7-default_h1323312_9.conda + sha256: c4b6b048f5666b12c6a1710181c639240c31763dd9b9d540709cf9e37b8a32db + md5: 3435d8341fc397a5c6a8676abd28e2ee + depends: + - cctools + - clang-19 19.1.7.* default_* + - clang_impl_osx-64 19.1.7 default_ha1a018a_9 + - ld64 + - ld64_osx-64 * llvm19_1_* + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 24913 + timestamp: 1776984881267 +- conda: https://conda.anaconda.org/conda-forge/osx-64/clang_impl_osx-64-19.1.7-default_ha1a018a_9.conda + sha256: dcf0d1bd251ac9c48875d38cd9434edf9833d7d23a26fc3b1f33c18181441c09 + md5: 72a199c17b7f87cad5e965a3c0352f9b + depends: + - cctools_impl_osx-64 + - clang-19 19.1.7.* default_* + - compiler-rt 19.1.7.* + - compiler-rt_osx-64 + - ld64_osx-64 * llvm19_1_* + - llvm-openmp >=19.1.7 + - llvm-tools 19.1.7.* + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 24878 + timestamp: 1776984866319 +- conda: https://conda.anaconda.org/conda-forge/osx-64/clang_osx-64-19.1.7-h8a78ed7_34.conda + sha256: 50dbf311b4debcf8e411034ce1ba52c349c9dd117d0d20654f7388d1488666c9 + md5: b6aa013be9df08b66252835546024e8e + depends: + - cctools_osx-64 + - clang 19.* + - clang_impl_osx-64 19.1.7.* + - sdkroot_env_osx-64 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 20610 + timestamp: 1785272440310 +- conda: https://conda.anaconda.org/conda-forge/osx-64/compiler-rt-19.1.7-he914875_1.conda + sha256: 28e5f0a6293acba68ebc54694a2fc40b1897202735e8e8cbaaa0e975ba7b235b + md5: e6b9e71e5cb08f9ed0185d31d33a074b + depends: + - __osx >=10.13 + - clang 19.1.7.* + - compiler-rt_osx-64 19.1.7.* + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 96722 + timestamp: 1757412473400 +- conda: https://conda.anaconda.org/conda-forge/osx-64/ld64-956.6-llvm19_1_hc3792c1_5.conda + sha256: 70883c0843a97704268897df195ed8b55f22928d355d39fd0bab6c70a6b58e47 + md5: 3da16215bcd248a294174ff0dd7724fd + depends: + - ld64_osx-64 956.6 llvm19_1_hf4e8e46_5 + - libllvm19 >=19.1.7,<19.2.0a0 + constrains: + - cctools 1030.6.3.* + - cctools_osx-64 1030.6.3.* + license: APSL-2.0 + license_family: Other + run_exports: {} + size: 21856 + timestamp: 1785271398499 +- conda: https://conda.anaconda.org/conda-forge/osx-64/ld64_osx-64-956.6-llvm19_1_hf4e8e46_5.conda + sha256: 121f17a44862c1a9c7fb86225fb7093b6b333dc8a56f71007870067ade38ba4c + md5: 990f5a32daf6aafb6a6e5abedced0e3c + depends: + - __osx >=11.0 + - libcxx + - libllvm19 >=19.1.7,<19.2.0a0 + - sigtool-codesign + - tapi >=1600.0.11.8,<1601.0a0 + constrains: + - ld64 956.6.* + - cctools_impl_osx-64 1030.6.3.* + - cctools 1030.6.3.* + - clang 19.1.* + license: APSL-2.0 + license_family: Other + run_exports: {} + size: 1111371 + timestamp: 1785271333113 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libclang-cpp19.1-19.1.7-default_h9399c5b_9.conda + sha256: 05845abab074f2fe17f2abe7d96eef967b3fa6552799399a00331995f6e5ffa2 + md5: 9382ae02bf45b4f8bd1e0fb0e5ee936c + depends: + - __osx >=11.0 + - libcxx >=19.1.7 + - libllvm19 >=19.1.7,<19.2.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: + weak: + - libclang-cpp19.1 >=19.1.7,<19.2.0a0 + size: 14856061 + timestamp: 1776984570408 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libclang-cpp22.1-22.1.8-default_h5a1b869_3.conda + sha256: f61fdbaf250135d9fe8981de0dbfbe8d4e983efcb94c6dc0d1b8b5fc8c21317d + md5: 300864557417d3d65d917181fb959c50 + depends: + - libcxx >=22.1.8 + - __osx >=11.0 + - libllvm22 >=22.1.8,<22.2.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: + weak: + - libclang-cpp22.1 >=22.1.8,<22.2.0a0 + size: 17143866 + timestamp: 1782358919535 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-22.1.8-h19cb2f5_0.conda + sha256: 57ee997f1f800cf38abc743c0f0a9ddfe6a101c697c35510452ce6f4ddf96361 + md5: 0f600157f28fc7bc9549ecafdfa5bc12 + depends: + - __osx >=11.0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 566717 + timestamp: 1781672189697 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libedit-3.1.20250104-pl5321ha958ccf_0.conda + sha256: 6cc49785940a99e6a6b8c6edbb15f44c2dd6c789d9c283e5ee7bdfedd50b4cd6 + md5: 1f4ed31220402fcddc083b4bff406868 + depends: + - ncurses + - __osx >=10.13 + - ncurses >=6.5,<7.0a0 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - libedit >=3.1.20250104,<3.2.0a0 + size: 115563 + timestamp: 1738479554273 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.8.1-hcc62823_1.conda + sha256: 9c96cc05e056e1bba5b545cbbd57b6e01db622dc2c82934caaaa25cfb22fe666 + md5: dcfdea7b7013beef0a4d744d776ea38f + depends: + - __osx >=11.0 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + run_exports: {} + size: 76020 + timestamp: 1781204303305 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-hd1f9c09_0.conda + sha256: 951958d1792238006fdc6fce7f71f1b559534743b26cc1333497d46e5903a2d6 + md5: 66a0dc7464927d0853b590b6f53ba3ea + depends: + - __osx >=10.13 + license: MIT + license_family: MIT + run_exports: + weak: + - libffi >=3.5.2,<3.6.0a0 + size: 53583 + timestamp: 1769456300951 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libiconv-1.18-h57a12c2_2.conda + sha256: a1c8cecdf9966921e13f0ae921309a1f415dfbd2b791f2117cf7e8f5e61a48b6 + md5: 210a85a1119f97ea7887188d176db135 + depends: + - __osx >=10.13 + license: LGPL-2.1-only + run_exports: + weak: + - libiconv >=1.18,<2.0a0 + size: 737846 + timestamp: 1754908900138 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libllvm19-19.1.7-h56e7563_2.conda + sha256: 375a634873b7441d5101e6e2a9d3a42fec51be392306a03a2fa12ae8edecec1a + md5: 05a54b479099676e75f80ad0ddd38eff + depends: + - __osx >=10.13 + - libcxx >=19 + - libxml2 + - libxml2-16 >=2.14.5 + - libzlib >=1.3.1,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: + weak: + - libllvm19 >=19.1.7,<19.2.0a0 + size: 28801374 + timestamp: 1757354631264 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libllvm22-22.1.8-hab754da_1.conda + sha256: c2bd652c5a6c4f0e6029786c4e59c54c09018049664006e94d674db4561238ea + md5: 8de4654ab7428c890ef09cee05e11b42 + depends: + - __osx >=11.0 + - libcxx >=19 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: + weak: + - libllvm22 >=22.1.8,<22.2.0a0 + size: 31843736 + timestamp: 1781793214214 +- conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_0.conda + sha256: d9e2006051529aec5578c6efeb13bb6a7200a014b2d5a77a579e83a8049d5f3c + md5: becdfbfe7049fa248e52aa37a9df09e2 + depends: + - __osx >=11.0 + constrains: + - xz 5.8.3.* + license: 0BSD + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 105724 + timestamp: 1775826029494 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-hf3981d6_1.conda + sha256: 1096c740109386607938ab9f09a7e9bca06d86770a284777586d6c378b8fb3fd + md5: ec88ba8a245855935b871a7324373105 + depends: + - __osx >=10.13 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 79899 + timestamp: 1769482558610 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libsigtool-0.1.3-h8c25ef7_1.conda + sha256: c3d76ff04ebed64d00a7ff5106297a55af20082a3d3df0ea7dfb235f94ba31c3 + md5: acc366a7706c83b5364f5f81e1b4857b + depends: + - __osx >=11.0 + - openssl >=3.5.7,<4.0a0 + license: MIT + run_exports: {} + size: 38559 + timestamp: 1786115361068 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libsigtool-0.1.3-hc0f2934_0.conda + sha256: f87b743d5ab11c1a8ddd800dd9357fc0fabe47686068232ddc1d1eed0d7321ec + md5: 3576aba85ce5e9ab15aa0ea376ab864b + depends: + - __osx >=10.13 + - openssl >=3.5.4,<4.0a0 + license: MIT + license_family: MIT + run_exports: {} + size: 38085 + timestamp: 1767044977731 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.53.4-h77d7759_0.conda + sha256: 5725d44a17d196adba9798a5fd9f692b7039a827cd6145556a072a80e1931c49 + md5: 993009426e1d3aa90eed155171bd59d8 + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: blessing + run_exports: + weak: + - libsqlite >=3.53.4,<4.0a0 + size: 1008531 + timestamp: 1785016345740 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-16-2.15.3-h0d7f165_0.conda + sha256: daa69a1dd887b2dbac44327bc5af73a4d41fa63bc6dc609782fdda9aec187895 + md5: 5eb194ed01ed3f5a64b6fcec1b399d96 + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - icu <0.0a0 + - libxml2 2.15.3 + license: MIT + license_family: MIT + run_exports: {} + size: 495267 + timestamp: 1776377547505 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libxml2-2.15.3-h0712280_0.conda + sha256: caf6e73fa53c3dec3227ea67de231b0f7009544252684e816c6f2f414aeb55c9 + md5: 81ac54c8b2eb51fceb94eb0817b93cf3 + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2-16 2.15.3 h0d7f165_0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - icu <0.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libxml2 + - libxml2-16 >=2.15.3 + size: 40803 + timestamp: 1776377589058 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_3.conda + sha256: b2dba286dd6632292b12296e761193b5ef9fb0eaeecaa481f5ba9af72c0c18e1 + md5: 7d3fa28263bb7f8ea32db11f570a5bb6 + depends: + - __osx >=11.0 + constrains: + - zlib 1.3.2 *_3 + license: Zlib + license_family: Other + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 58993 + timestamp: 1785276808631 +- conda: https://conda.anaconda.org/conda-forge/osx-64/lldb-22.1.8-py314h8d3d9e6_0.conda + sha256: 42e07e82c3a5a56dafc769b55b710f7cdee8a6527afde2d49e75e64928a25ce5 + md5: 11332f491374b08e12a03887b80586b5 + depends: + - __osx >=11.0 + - libclang-cpp22.1 >=22.1.8,<22.2.0a0 + - libcxx >=19 + - libedit >=3.1.20250104,<3.2.0a0 + - libllvm22 >=22.1.8,<22.2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - six + - zstd >=1.5.7,<1.6.0a0 + constrains: + - llvmdev ==22.1.8 + - clangdev ==22.1.8 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 7367486 + timestamp: 1784669859234 +- conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-openmp-22.1.8-h0d3cbff_0.conda + sha256: 7e8dcf03c2ef5491405d6d86eb892d14e99902f50f4eeb250db0cbdc58dd5818 + md5: 9d5828c46147a47f828ca47a18407621 + depends: + - __osx >=11.0 + constrains: + - openmp 22.1.8|22.1.8.* + - intel-openmp <0.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: + strong: + - llvm-openmp >=22.1.8 + size: 311645 + timestamp: 1781737360942 +- conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-tools-19-19.1.7-h879f4bc_2.conda + sha256: fd281acb243323087ce672139f03a1b35ceb0e864a3b4e8113b9c23ca1f83bf0 + md5: bf644c6f69854656aa02d1520175840e + depends: + - __osx >=10.13 + - libcxx >=19 + - libllvm19 19.1.7 h56e7563_2 + - libzlib >=1.3.1,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 17198870 + timestamp: 1757354915882 +- conda: https://conda.anaconda.org/conda-forge/osx-64/llvm-tools-19.1.7-hb0207f0_2.conda + sha256: 8d042ee522bc9eb12c061f5f7e53052aeb4f13e576e624c8bebaf493725b95a0 + md5: 0f79b23c03d80f22ce4fe0022d12f6d2 + depends: + - __osx >=10.13 + - libllvm19 19.1.7 h56e7563_2 + - llvm-tools-19 19.1.7 h879f4bc_2 + constrains: + - llvmdev 19.1.7 + - llvm 19.1.7 + - clang 19.1.7 + - clang-tools 19.1.7 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 87962 + timestamp: 1757355027273 +- conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_0.conda + sha256: f5f7e006ff4271305ab4cc08eedd855c67a571793c3d18aff73f645f088a8cae + md5: 31b8740cf1b2588d4e61c81191004061 + depends: + - __osx >=11.0 + license: X11 AND BSD-3-Clause + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 831711 + timestamp: 1777423052277 +- conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.3-hc881268_1.conda + sha256: d43abd09a455847108fc821e81cf4e36dba31755263a1313b6f1b538ac218998 + md5: da403ed66c373b5fb25266b4be662327 + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 2773506 + timestamp: 1785915436200 +- conda: https://conda.anaconda.org/conda-forge/osx-64/pkg-config-0.29.2-hf7e621a_1009.conda + sha256: 636122606556b651ad4d0ac60c7ab6b379e98f390359a1f0c05ad6ba6fb3837f + md5: 0b1b9f9e420e4a0e40879b61f94ae646 + depends: + - __osx >=10.13 + - libiconv >=1.17,<2.0a0 + license: GPL-2.0-or-later + license_family: GPL + run_exports: {} + size: 239818 + timestamp: 1720806136579 +- conda: https://conda.anaconda.org/conda-forge/osx-64/pydantic-core-2.46.4-py314h8916c15_0.conda + sha256: 555021648575077c62d429feb4530c724915af6f92e3d77f2accb4789c071778 + md5: a98f01ef277ca30e27a29bda4168b158 + depends: + - python + - typing-extensions >=4.6.0,!=4.7.0 + - __osx >=11.0 + - python_abi 3.14.* *_cp314 + constrains: + - __osx >=10.13 + license: MIT + license_family: MIT + run_exports: {} + size: 1883108 + timestamp: 1778084289874 +- conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.14.6-h7c6738f_101_cp314.conda + build_number: 101 + sha256: 08c788453bdf61071e075a97684291286877536ee92f23a73b4127f1b85bdbcd + md5: c773b2aa854bf3d37e26aeb677c0e732 + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.3,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + run_exports: + weak: + - python_abi 3.14.* *_cp314 + noarch: + - python + size: 14497574 + timestamp: 1784958042787 + python_site_packages_path: lib/python3.14/site-packages +- conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda + sha256: 4614af680aa0920e82b953fece85a03007e0719c3399f13d7de64176874b80d5 + md5: eefd65452dfe7cce476a519bece46704 + depends: + - __osx >=10.13 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: + weak: + - readline >=8.3,<9.0a0 + size: 317819 + timestamp: 1765813692798 +- conda: https://conda.anaconda.org/conda-forge/osx-64/rust-1.96.1-h5655b98_2.conda + sha256: db31fd23795a89604dd04028dc956704ca71b583ab8935ad992bcd584de28980 + md5: fba055069cac94ae97ea1389220868c9 + depends: + - rust-std-x86_64-apple-darwin 1.96.1 h38e4360_2 + license: MIT + license_family: MIT + run_exports: + strong_constrains: + - __osx >=11.0 + size: 199617433 + timestamp: 1783677125703 +- conda: https://conda.anaconda.org/conda-forge/osx-64/rust-analyzer-2026.04.27-h19f9e61_0.conda + sha256: 96386cdaa0e09368000b4264e09f8e825fb497e2e7aee28bdf543d92d0758adb + md5: 07fb8f3710ad19b8d6c4adfc5d52dc8b + depends: + - __osx >=11.0 + constrains: + - __osx >=10.13 + license: MIT OR Apache-2.0 + run_exports: {} + size: 11567252 + timestamp: 1777398362461 +- conda: https://conda.anaconda.org/conda-forge/osx-64/sigtool-codesign-0.1.3-h8c25ef7_1.conda + sha256: 37aac42f05e21b48a3b61b28ae624ed5a3d2acfcbaabf2cd10ef7762c4fb4c45 + md5: e7eb95a1f841fbbb8312dcd65963334f + depends: + - __osx >=11.0 + - libsigtool 0.1.3 h8c25ef7_1 + - openssl >=3.5.7,<4.0a0 + license: MIT + run_exports: {} + size: 123458 + timestamp: 1786115396108 +- conda: https://conda.anaconda.org/conda-forge/osx-64/sigtool-codesign-0.1.3-hc0f2934_0.conda + sha256: b89d89d0b62e0a84093205607d071932cca228d4d6982a5b073eec7e765b146d + md5: 1261fc730f1d8af7eeea8a0024b23493 + depends: + - __osx >=10.13 + - libsigtool 0.1.3 hc0f2934_0 + - openssl >=3.5.4,<4.0a0 + license: MIT + license_family: MIT + run_exports: {} + size: 123083 + timestamp: 1767045007433 +- conda: https://conda.anaconda.org/conda-forge/osx-64/tapi-1600.0.11.8-h44950e2_3.conda + sha256: ec381e40cf1caf8496265bbf14ca0d2cb947495cd3e9069947e2fa75f7de7b3b + md5: 9b80c76b01a3a3a78d188d5055ad47a4 + depends: + - libcxx >=19.0.0.a0 + - __osx >=11.0 + - ncurses >=6.6,<7.0a0 + license: NCSA + run_exports: {} + size: 214093 + timestamp: 1785906287768 +- conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-hb794df6_3.conda + sha256: 670a364b8285887e5738880bb026f721af2662cfefe9ef64aa9e93eff1981535 + md5: bc699b366e49399bf8e5c6de99bb8cfb + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: TCL + run_exports: + weak: + - tk >=8.6.13,<8.7.0a0 + size: 3516600 + timestamp: 1784229134070 +- conda: https://conda.anaconda.org/conda-forge/osx-64/typst-0.15.1-h19f9e61_0.conda + sha256: 41bcea9d12551b7eef7574e8d9a021e18c5a3ca67ddb5c030ef7993bd41ed9d2 + md5: e3b738225b07046e2bff6bc6ba83d6bf + depends: + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 18382502 + timestamp: 1784301298466 +- conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda + sha256: 47101a4055a70a4876ffc87b750ab2287b67eca793f21c8224be5e1ee6394d3f + md5: 727109b184d680772e3122f40136d5ca + depends: + - __osx >=10.13 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - zstd >=1.5.7,<1.6.0a0 + size: 528148 + timestamp: 1764777156963 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda + sha256: 8ec22f0ba25cbfc2e64d70cf29459eccd7ffdf6436f6a6ff15bbfef799f7d4f6 + md5: b50612e7d190b8061ab4e7dc119cf4d5 + depends: + - __osx >=11.0 + license: bzip2-1.0.6 + license_family: BSD + run_exports: + weak: + - bzip2 >=1.0.8,<2.0a0 + size: 124965 + timestamp: 1785906749812 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/c-compiler-1.11.0-h61f9b84_0.conda + sha256: b51bd1551cfdf41500f732b4bd1e4e70fb1e74557165804a648f32fa9c671eec + md5: 148516e0c9edf4e9331a4d53ae806a9b + depends: + - cctools >=949.0.1 + - clang_osx-arm64 19.* + - ld64 >=530 + - llvm-openmp + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 6697 + timestamp: 1753098737760 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cargo-nextest-0.9.143-h6fdd925_0.conda + sha256: 7223ffed672ff5a5f07b366b383844a8d23a0e4f90aa5aaa5b7d54b60483999c + md5: 66cb5634871330af7a20b66a3b52f87d + depends: + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: MIT + run_exports: {} + size: 6504284 + timestamp: 1785902364923 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools-1030.6.3-llvm19_1_hd01ab73_5.conda + sha256: c72058e16dc45c3e565613bdf7c075354963e4b869a3ff76be4d0a6160c7ed76 + md5: 4ac48b22145b0cf5fcb9cbb48d48733b + depends: + - cctools_impl_osx-arm64 1030.6.3 llvm19_1_hc7668c6_5 + - ld64 956.6 llvm19_1_he86490a_5 + - libllvm19 >=19.1.7,<19.2.0a0 + license: APSL-2.0 + license_family: Other + run_exports: {} + size: 24481 + timestamp: 1785270932465 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools_impl_osx-arm64-1030.6.3-llvm19_1_hc7668c6_5.conda + sha256: 2cae8beaf880fbf92ccd4534e6ba0463259512587bb6cc0128798ca5c4305d2c + md5: 5fbea6ce6ff8af995623a85b5215df26 + depends: + - __osx >=11.0 + - ld64_osx-arm64 >=956.6,<956.7.0a0 + - libcxx + - libllvm19 >=19.1.7,<19.2.0a0 + - libzlib >=1.3.2,<2.0a0 + - llvm-tools 19.1.* + - sigtool-codesign + constrains: + - ld64 956.6.* + - clang 19.1.* + - cctools 1030.6.3.* + license: APSL-2.0 + license_family: Other + run_exports: {} + size: 748451 + timestamp: 1785270905693 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cctools_osx-arm64-1030.6.3-llvm19_1_hd01ab73_5.conda + sha256: dcaffeb8cef0915519488d8ed767e9fec5af23598de0737ef3bc4256339d9382 + md5: acb70bf225797b6faf8d14e56f201074 + depends: + - cctools_impl_osx-arm64 1030.6.3 llvm19_1_hc7668c6_5 + - ld64_osx-arm64 956.6 llvm19_1_ha2625f7_5 + constrains: + - cctools 1030.6.3.* + license: APSL-2.0 + license_family: Other + run_exports: {} + size: 23652 + timestamp: 1785270935447 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang-19-19.1.7-default_hf3020a7_9.conda + sha256: a1449c64f455d43153036f54c68cb075a52c1d9f3350a91f4a8936ecf1675c6b + md5: 5a77d772c22448f6ab340fbfff55db48 + depends: + - __osx >=11.0 + - libclang-cpp19.1 19.1.7 default_hf3020a7_9 + - libcxx >=19.1.7 + - libllvm19 >=19.1.7,<19.2.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 763361 + timestamp: 1776988759708 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang-19.1.7-default_hf9bcbb7_9.conda + sha256: 8268c23a000cfeee1b83e19c59eb018ec07583905f69bfee01beac8aedd8c4df + md5: 20056c993a8c9df01e04a0e165579ec1 + depends: + - cctools + - clang-19 19.1.7.* default_* + - clang_impl_osx-arm64 19.1.7 default_hc11f16d_9 + - ld64 + - ld64_osx-arm64 * llvm19_1_* + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 24962 + timestamp: 1776989044302 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang_impl_osx-arm64-19.1.7-default_hc11f16d_9.conda + sha256: 56db3a98eda7032a0aefe38f146a4b29df9d75d08c71bf7f7d6412effe775dd1 + md5: 2aec2e39be3b4999bda2a3e5bd4cd2e6 + depends: + - cctools_impl_osx-arm64 + - clang-19 19.1.7.* default_* + - compiler-rt 19.1.7.* + - compiler-rt_osx-arm64 + - ld64_osx-arm64 * llvm19_1_* + - llvm-openmp >=19.1.7 + - llvm-tools 19.1.7.* + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 24905 + timestamp: 1776989025990 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/clang_osx-arm64-19.1.7-h75f8d18_34.conda + sha256: 307875e8c6e41c59adc5aea0ac2e4980e2f85574b98738936810dac2b30e9d52 + md5: a687765928cf0ad08ba9786f1da5c59b + depends: + - cctools_osx-arm64 + - clang 19.* + - clang_impl_osx-arm64 19.1.7.* + - sdkroot_env_osx-arm64 + license: BSD-3-Clause + license_family: BSD + run_exports: {} + size: 20718 + timestamp: 1785272092513 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/compiler-rt-19.1.7-h855ad52_1.conda + sha256: b58a481828aee699db7f28bfcbbe72fb133277ac60831dfe70ee2465541bcb93 + md5: 39451684370ae65667fa5c11222e43f7 + depends: + - __osx >=11.0 + - clang 19.1.7.* + - compiler-rt_osx-arm64 19.1.7.* + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 97085 + timestamp: 1757411887557 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hc7cc350_2.conda + sha256: f0b22bc30e4cc29e29ba3234cb38497fe8def2c2aae4b775d42fe5b378a018c9 + md5: 6133ddbb17ba2b50700dd88e9303ce27 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: + weak: + - icu >=78.3,<79.0a0 + size: 14070698 + timestamp: 1784916459058 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ld64-956.6-llvm19_1_he86490a_5.conda + sha256: 65a01b4adde1c1c7f4f6195590e90280bcb6ac34c03a81297a594a7830003c31 + md5: 972af8a8dee1002cc8a9d331d273326c + depends: + - ld64_osx-arm64 956.6 llvm19_1_ha2625f7_5 + - libllvm19 >=19.1.7,<19.2.0a0 + constrains: + - cctools_osx-arm64 1030.6.3.* + - cctools 1030.6.3.* + license: APSL-2.0 + license_family: Other + run_exports: {} + size: 21905 + timestamp: 1785270919676 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ld64_osx-arm64-956.6-llvm19_1_ha2625f7_5.conda + sha256: 430018eaf9d94fa663be4535ce94e32ca9be782b2498b4db497bfb73ea643c01 + md5: d92bcd8b46949ed3390b0c2c313eae49 + depends: + - __osx >=11.0 + - libcxx + - libllvm19 >=19.1.7,<19.2.0a0 + - sigtool-codesign + - tapi >=1600.0.11.8,<1601.0a0 + constrains: + - ld64 956.6.* + - clang 19.1.* + - cctools_impl_osx-arm64 1030.6.3.* + - cctools 1030.6.3.* + license: APSL-2.0 + license_family: Other + run_exports: {} + size: 1038517 + timestamp: 1785270876807 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libclang-cpp19.1-19.1.7-default_hf3020a7_9.conda + sha256: e05c4830a117492996bac1ad55cd7ee3e57f63b46da8a324862efbee9279ab44 + md5: ddb70ebdcbf3a44bddc2657a51faf490 + depends: + - __osx >=11.0 + - libcxx >=19.1.7 + - libllvm19 >=19.1.7,<19.2.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: + weak: + - libclang-cpp19.1 >=19.1.7,<19.2.0a0 + size: 14064699 + timestamp: 1776988581784 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libclang-cpp22.1-22.1.8-default_hdca5a3d_3.conda + sha256: dcc960219fae62d99281e3767b6b3162b15aa53331ac0233c6d72ed99e5a1fbe + md5: 996a036eabb7a8594626fdc1cf758519 + depends: + - libcxx >=22.1.8 + - __osx >=11.0 + - libllvm22 >=22.1.8,<22.2.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: + weak: + - libclang-cpp22.1 >=22.1.8,<22.2.0a0 + size: 15930788 + timestamp: 1782358827352 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda + sha256: a2e7abab5add9750fab064c024394de48e49f97631c605ad5db5c8ac3fc769ef + md5: 89f76a2a21a3ec3ec983b5eb237c4113 + depends: + - __osx >=11.0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 569349 + timestamp: 1781670209146 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20250104-pl5321hafb1f1b_0.conda + sha256: 66aa216a403de0bb0c1340a88d1a06adaff66bae2cfd196731aa24db9859d631 + md5: 44083d2d2c2025afca315c7a172eab2b + depends: + - ncurses + - __osx >=11.0 + - ncurses >=6.5,<7.0a0 + license: BSD-2-Clause + license_family: BSD + run_exports: + weak: + - libedit >=3.1.20250104,<3.2.0a0 + size: 107691 + timestamp: 1738479560845 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + sha256: 5af74261101e3c777399c6294b2b5d290e508153268eb2e9ff99c4d69834612f + md5: a915151d5d3c5bf039f5ccc8402a436f + depends: + - __osx >=11.0 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + run_exports: {} + size: 69362 + timestamp: 1781203631990 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + sha256: 6686a26466a527585e6a75cc2a242bf4a3d97d6d6c86424a441677917f28bec7 + md5: 43c04d9cb46ef176bb2a4c77e324d599 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: + weak: + - libffi >=3.5.2,<3.6.0a0 + size: 40979 + timestamp: 1769456747661 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libglib-2.88.3-ha08bb59_0.conda + sha256: a14417ae1f3f4a92c5766354eb280342fa6aae72a09af86bf7fcfc12a8c9225c + md5: 4a9309d9502a08a2d29b1743dcfb0e7f + depends: + - __osx >=11.0 + - pcre2 >=10.47,<10.48.0a0 + - libzlib >=1.3.2,<2.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libintl >=0.25.1,<1.0a0 + - libiconv >=1.18,<2.0a0 + constrains: + - glib >2.66 + license: LGPL-2.1-or-later + run_exports: + weak: + - libglib >=2.88.3,<3.0a0 + size: 4448109 + timestamp: 1785442168180 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.18-h23cfdf5_2.conda + sha256: de0336e800b2af9a40bdd694b03870ac4a848161b35c8a2325704f123f185f03 + md5: 4d5a7445f0b25b6a3ddbb56e790f5251 + depends: + - __osx >=11.0 + license: LGPL-2.1-only + run_exports: + weak: + - libiconv >=1.18,<2.0a0 + size: 750379 + timestamp: 1754909073836 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libintl-0.25.1-h493aca8_0.conda + sha256: 99d2cebcd8f84961b86784451b010f5f0a795ed1c08f1e7c76fbb3c22abf021a + md5: 5103f6a6b210a3912faf8d7db516918c + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + license: LGPL-2.1-or-later + run_exports: + weak: + - libintl >=0.25.1,<1.0a0 + size: 90957 + timestamp: 1751558394144 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libllvm19-19.1.7-h8e0c9ce_2.conda + sha256: 46f8ff3d86438c0af1bebe0c18261ce5de9878d58b4fe399a3a125670e4f0af5 + md5: d1d9b233830f6631800acc1e081a9444 + depends: + - __osx >=11.0 + - libcxx >=19 + - libxml2 + - libxml2-16 >=2.14.5 + - libzlib >=1.3.1,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: + weak: + - libllvm19 >=19.1.7,<19.2.0a0 + size: 26914852 + timestamp: 1757353228286 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libllvm22-22.1.8-h89af1be_1.conda + sha256: 9c0ece5160e3d8749f43f8dd86777be4cfdb51490fb32f4d8269f8fd9866ce55 + md5: 5726aa6dda93e10bd614e434e9c9b8fc + depends: + - __osx >=11.0 + - libcxx >=19 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: + weak: + - libllvm22 >=22.1.8,<22.2.0a0 + size: 30057877 + timestamp: 1781783269086 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + sha256: 34878d87275c298f1a732c6806349125cebbf340d24c6c23727268184bba051e + md5: b1fd823b5ae54fbec272cea0811bd8a9 + depends: + - __osx >=11.0 + constrains: + - xz 5.8.3.* + license: 0BSD + run_exports: + weak: + - liblzma >=5.8.3,<6.0a0 + size: 92472 + timestamp: 1775825802659 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + sha256: 1089c7f15d5b62c622625ec6700732ece83be8b705da8c6607f4dabb0c4bd6d2 + md5: 57c4be259f5e0b99a5983799a228ae55 + depends: + - __osx >=11.0 + license: BSD-2-Clause + license_family: BSD + run_exports: {} + size: 73690 + timestamp: 1769482560514 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsigtool-0.1.3-h98dc951_0.conda + sha256: 421f7bd7caaa945d9cd5d374cc3f01e75637ca7372a32d5e7695c825a48a30d1 + md5: c08557d00807785decafb932b5be7ef5 + depends: + - __osx >=11.0 + - openssl >=3.5.4,<4.0a0 + license: MIT + license_family: MIT + run_exports: {} + size: 36416 + timestamp: 1767045062496 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsigtool-0.1.3-h98dc951_1.conda + sha256: fcfa03afe31f40dfabf011629d99836adbebbc3ed1b38c7e9eee9ffc7581975b + md5: c70eb797aa92a294b09ef8f41f6bd578 + depends: + - __osx >=11.0 + - openssl >=3.5.7,<4.0a0 + license: MIT + run_exports: {} + size: 36651 + timestamp: 1786115069018 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1ae2325_0.conda + sha256: 745662565e103f290e9dc4263bbd88285082f8cf699854fe2d5f1e35a4a0d326 + md5: 0e3477c0c3e718dcf2eb74ccc8f68570 + depends: + - __osx >=11.0 + - icu >=78.3,<79.0a0 + - libzlib >=1.3.2,<2.0a0 + license: blessing + run_exports: + weak: + - libsqlite >=3.53.4,<4.0a0 + size: 929203 + timestamp: 1785016131414 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.4-h1b79a29_0.conda + sha256: 9c50de03f8ff9f7e57fc5c13748736bae0538b93421eca0d3471ccb93f8b3d19 + md5: 4aad9a4de332ab9ce571ef3901810b32 + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: blessing + run_exports: + weak: + - libsqlite >=3.53.4,<4.0a0 + size: 925226 + timestamp: 1785016124520 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.3-h5ef1a60_0.conda + sha256: ff75b84cdb9e8d123db2fa694a8ac2c2059516b6cbc98ac21fb68e235d0fd354 + md5: 19edaa53885fc8205614b03da2482282 + depends: + - __osx >=11.0 + - icu >=78.3,<79.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - libxml2 2.15.3 + license: MIT + license_family: MIT + run_exports: {} + size: 466360 + timestamp: 1776377102261 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-16-2.15.3-h6967ea9_0.conda + sha256: 43895a7517c055b8893531290f9dc48bd751eb04be04f14bbce3b6c71b052be6 + md5: 6c8292c2ee808aeef2406083beaa6da7 + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - libxml2 2.15.3 + - icu <0.0a0 + license: MIT + license_family: MIT + run_exports: {} + size: 465820 + timestamp: 1776377317454 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-h5654f7c_0.conda + sha256: 2fe1d8de0854342ae9cabe408b476935f82f5636e153b3b497456264dc8ff3a1 + md5: 8e037d73747d6fe34e12d7bcac10cf21 + depends: + - __osx >=11.0 + - icu >=78.3,<79.0a0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2-16 2.15.3 h5ef1a60_0 + - libzlib >=1.3.2,<2.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libxml2 + - libxml2-16 >=2.15.3 + size: 41102 + timestamp: 1776377119495 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.15.3-heed7d32_0.conda + sha256: 4d9c117b2dd222cf891710d5f6a570ebb275479979843a1477ac54ed50907b40 + md5: 0c1fdc80534d8f25fd74722aba81f044 + depends: + - __osx >=11.0 + - libiconv >=1.18,<2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2-16 2.15.3 h6967ea9_0 + - libzlib >=1.3.2,<2.0a0 + constrains: + - icu <0.0a0 + license: MIT + license_family: MIT + run_exports: + weak: + - libxml2 + - libxml2-16 >=2.15.3 + size: 41663 + timestamp: 1776377341241 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda + sha256: a18fa5d5bac452401459f966cf0d872224e8080c4ff93c77e168d43ab42ef9d7 + md5: f39288f0ea63ae962e1a2e4f355a0d75 + depends: + - __osx >=11.0 + constrains: + - zlib 1.3.2 *_3 + license: Zlib + license_family: Other + run_exports: + weak: + - libzlib >=1.3.2,<2.0a0 + size: 47822 + timestamp: 1785277049190 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/lldb-22.1.8-py314h68e168f_0.conda + sha256: 75ed802a9377e338fb761b07dae88a105aadcbd6f0c4d2e7f448d7e3d35ceb0f + md5: 3582f37dadbf111d6b9f262576dca61e + depends: + - __osx >=11.0 + - libclang-cpp22.1 >=22.1.8,<22.2.0a0 + - libedit >=3.1.20250104,<3.2.0a0 + - libllvm22 >=22.1.8,<22.2.0a0 + - liblzma >=5.8.3,<6.0a0 + - libxml2 + - libxml2-16 >=2.14.6 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - python >=3.14,<3.15.0a0 + - python_abi 3.14.* *_cp314 + - six + - zstd >=1.5.7,<1.6.0a0 + constrains: + - clangdev ==22.1.8 + - llvmdev ==22.1.8 + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: {} + size: 7150639 + timestamp: 1784664672305 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-22.1.8-hc7d1edf_0.conda + sha256: ccbaad6bbc88f135ab849bc36af5fa6eda36a9ed18ce6f58e3dde3d11784c156 + md5: a9c118f6343fb6301b6f3b4e94c4c562 + depends: + - __osx >=11.0 + constrains: + - intel-openmp <0.0a0 + - openmp 22.1.8|22.1.8.* + license: Apache-2.0 WITH LLVM-exception + license_family: APACHE + run_exports: + strong: + - llvm-openmp >=22.1.8 + size: 286313 + timestamp: 1781736516782 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-19-19.1.7-h91fd4e7_2.conda + sha256: 73f9506f7c32a448071340e73a0e8461e349082d63ecc4849e3eb2d1efc357dd + md5: 8237b150fcd7baf65258eef9a0fc76ef + depends: + - __osx >=11.0 + - libcxx >=19 + - libllvm19 19.1.7 h8e0c9ce_2 + - libzlib >=1.3.1,<2.0a0 + - zstd >=1.5.7,<1.6.0a0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 16376095 + timestamp: 1757353442671 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-tools-19.1.7-h855ad52_2.conda + sha256: 09750c33b5d694c494cad9eafda56c61a62622264173d760341b49fb001afe82 + md5: 3e3ac06efc5fdc1aa675ca30bf7d53df + depends: + - __osx >=11.0 + - libllvm19 19.1.7 h8e0c9ce_2 + - llvm-tools-19 19.1.7 h91fd4e7_2 + constrains: + - llvm 19.1.7 + - llvmdev 19.1.7 + - clang-tools 19.1.7 + - clang 19.1.7 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + run_exports: {} + size: 88390 + timestamp: 1757353535760 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + sha256: 4ea6c620b87bd1d42bb2ccc2c87cd2483fa2d7f9e905b14c223f11ff3f4c455d + md5: 343d10ed5b44030a2f67193905aea159 + depends: + - __osx >=11.0 + license: X11 AND BSD-3-Clause + run_exports: + weak: + - ncurses >=6.6,<7.0a0 + size: 805509 + timestamp: 1777423252320 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_1.conda + sha256: 66be2283b5b37dcda1332b5e74c1782a8cb14fd2e62e0d38017c2d35bf73c119 + md5: 65d1906712b85d1679263c518d011b5b + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + run_exports: + weak: + - openssl >=3.6.3,<4.0a0 + size: 3109132 + timestamp: 1785913735357 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pcre2-10.47-h30297fc_0.conda + sha256: 5e2e443f796f2fd92adf7978286a525fb768c34e12b1ee9ded4000a41b2894ba + md5: 9b4190c4055435ca3502070186eba53a + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - pcre2 >=10.47,<10.48.0a0 + size: 850231 + timestamp: 1763655726735 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pkg-config-0.29.2-hde07d2e_1009.conda + sha256: d82f4655b2d67fe12eefe1a3eea4cd27d33fa41dbc5e9aeab5fd6d3d2c26f18a + md5: b4f41e19a8c20184eec3aaf0f0953293 + depends: + - __osx >=11.0 + - libglib >=2.80.3,<3.0a0 + - libiconv >=1.17,<2.0a0 + license: GPL-2.0-or-later + license_family: GPL + run_exports: {} + size: 49724 + timestamp: 1720806128118 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pydantic-core-2.46.4-py314h54f3292_0.conda + sha256: c034a7cc16f279c260d3456fcfc625838495a7f9f55aa79408a629a427769bb2 + md5: 16314d88257820013e698381af19153f + depends: + - python + - typing-extensions >=4.6.0,!=4.7.0 + - __osx >=11.0 + - python 3.14.* *_cp314 + - python_abi 3.14.* *_cp314 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + run_exports: {} + size: 1722694 + timestamp: 1778084354332 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.6-h156bc91_101_cp314.conda + build_number: 101 + sha256: fc70ae73df7798bce7cac7adef7fdfb874208b2623a0e8ccb4354194b8508769 + md5: 6e9670f5238dfb27ef4f6364ed536cc0 + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.3,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.14.* *_cp314 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - zstd >=1.5.7,<1.6.0a0 + license: Python-2.0 + run_exports: + weak: + - python_abi 3.14.* *_cp314 + noarch: + - python + size: 14035244 + timestamp: 1784909523029 + python_site_packages_path: lib/python3.14/site-packages +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + sha256: a77010528efb4b548ac2a4484eaf7e1c3907f2aec86123ed9c5212ae44502477 + md5: f8381319127120ce51e081dce4865cf4 + depends: + - __osx >=11.0 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + run_exports: + weak: + - readline >=8.3,<9.0a0 + size: 313930 + timestamp: 1765813902568 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-1.96.1-h4ff7c5d_2.conda + sha256: 1a47cb0a6e0995ff52b59ed7062b6b61d0a75077d6fc883cd7d3f4b2c74b99c4 + md5: 84df2de148728d9db42c5f50bfa7f79b + depends: + - rust-std-aarch64-apple-darwin 1.96.1 hf6ec828_2 + license: MIT + license_family: MIT + run_exports: {} + size: 180667072 + timestamp: 1783676844332 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/rust-analyzer-2026.04.27-h6fdd925_0.conda + sha256: 61726acfa8bd0db24df719c7f9eaa313d820dee7069127966c5de85e9c51073c + md5: 5358ed78cd38f9bef5206cc0e1165f23 + depends: + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: MIT OR Apache-2.0 + run_exports: {} + size: 10609827 + timestamp: 1777398436955 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/sigtool-codesign-0.1.3-h98dc951_0.conda + sha256: f3d006e2441f110160a684744d90921bbedbffa247d7599d7e76b5cd048116dc + md5: ade77ad7513177297b1d75e351e136ce + depends: + - __osx >=11.0 + - libsigtool 0.1.3 h98dc951_0 + - openssl >=3.5.4,<4.0a0 + license: MIT + license_family: MIT + run_exports: {} + size: 114331 + timestamp: 1767045086274 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/sigtool-codesign-0.1.3-h98dc951_1.conda + sha256: 7efd6d7d18bf9cc5788bfe1c1e1620d9dbbe00a81c646814929a82ff31944cf3 + md5: a322c0a0d3b5be99ee11f9ccec99b60e + depends: + - __osx >=11.0 + - libsigtool 0.1.3 h98dc951_1 + - openssl >=3.5.7,<4.0a0 + license: MIT + run_exports: {} + size: 114653 + timestamp: 1786115093248 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tapi-1600.0.11.8-hb561403_3.conda + sha256: 9f1cc109e6e57861110e9de6e03beca7fd2b7fbdb46124cccc07990b0844d88a + md5: 0676b8719dd1e9bb1003e59a481c6c3e + depends: + - libcxx >=19.0.0.a0 + - __osx >=11.0 + - ncurses >=6.6,<7.0a0 + license: NCSA + run_exports: {} + size: 200397 + timestamp: 1785906507697 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hd3d0363_3.conda + sha256: 47186bc7ab8d7e8bee86bbd1a917196f8c21cf63f081fc33cd6d1221af087580 + md5: 8e3cf0e455e6b54519f0b1c72c61780a + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: TCL + run_exports: + weak: + - tk >=8.6.13,<8.7.0a0 + size: 3338712 + timestamp: 1784229090530 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/typst-0.15.1-h6fdd925_0.conda + sha256: 8a81909ce1d28c0e864d9fbdbc09807d8053ea981fb8283376b92e4da3e97a9d + md5: 319531113753afa1f6a02b85d70588bb + depends: + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: Apache-2.0 + license_family: APACHE + run_exports: {} + size: 17422072 + timestamp: 1784301252786 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda + sha256: 9485ba49e8f47d2b597dd399e88f4802e100851b27c21d7525625b0b4025a5d9 + md5: ab136e4c34e97f34fb621d2592a393d8 + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + run_exports: + weak: + - zstd >=1.5.7,<1.6.0a0 + size: 433413 + timestamp: 1764777166076 diff --git a/pixi.toml b/pixi.toml new file mode 100644 index 0000000..36b7112 --- /dev/null +++ b/pixi.toml @@ -0,0 +1,73 @@ +[workspace] +name = "coursebank" +version = "26.8.0" +authors = ["Scientific Computing Studio "] +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" } diff --git a/src/analysis.rs b/src/analysis.rs new file mode 100644 index 0000000..2d24d25 --- /dev/null +++ b/src/analysis.rs @@ -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; diff --git a/src/analysis/calibrate.rs b/src/analysis/calibrate.rs new file mode 100644 index 0000000..880a87b --- /dev/null +++ b/src/analysis/calibrate.rs @@ -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, + /// 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, + /// Items that were analyzed but could not be matched to a bank item. + pub unmatched: Vec, + /// Cautions worth printing before the diff. + pub warnings: Vec, + /// How many administrations were pooled. + pub administrations: Vec, +} + +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 { + 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 = BTreeMap::new(); + let mut administrations: BTreeSet = BTreeSet::new(); + + for admin in stored.administrations() { + let rows: Vec = 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> = BTreeMap::new(); + let mut unmatched: BTreeSet = 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, + /// Examinee-weighted discrimination index. + discrimination_index: Option, + /// Pooled per-option statistics. + option_stats: BTreeMap, + /// The union of flags raised in any administration. + flags: Vec, +} + +/// 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 = BTreeSet::new(); + + // Per-option accumulators, since option letters are stable across forms even + // when the printed order is not. + let mut rate_weighted: BTreeMap = BTreeMap::new(); + let mut option_rpb: BTreeMap = BTreeMap::new(); + let mut upper: BTreeMap = BTreeMap::new(); + let mut lower: BTreeMap = 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, letter: &str| -> Option { + m.get(letter) + .filter(|(_, w)| *w > 0.0) + .map(|(sum, w)| round4(sum / w)) + }; + + let option_stats: BTreeMap = 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 { + let mut out = Vec::new(); + let show = |label: &str, before: Option, after: Option, out: &mut Vec| 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 = p + .map(|c| c.flags.iter().copied().collect()) + .unwrap_or_default(); + let after_flags: BTreeSet = 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> { + // 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 = 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, 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")); + } +} diff --git a/src/analysis/classical.rs b/src/analysis/classical.rs new file mode 100644 index 0000000..a46f135 --- /dev/null +++ b/src/analysis/classical.rs @@ -0,0 +1,1055 @@ +// SPDX-License-Identifier: Prosperity-3.0.0 +// Copyright Scientific Computing Studio +// Source: https://git.scient.ing/education/coursebank + +//! Classical item analysis. +//! +//! The corrected point-biserial is the one to look at first. It correlates +//! each student's response with their total score on the other items, which is +//! the only version that answers the question you care about: did students who +//! knew the material get this right? The uncorrected version includes the item in +//! its own criterion and is therefore biased upward, which flatters bad items. +//! A negative corrected point-biserial almost always means the key is wrong or +//! the stem has a second defensible reading, and it is the single most reliable +//! signal in the whole tool. +//! +//! The p-value is difficulty, and on its own it means very little. An item +//! everyone answered correctly is not a bad item; it may be a deliberate anchor. +//! An item everyone missed is only a problem if it also failed to discriminate. +//! +//! Distractor analysis is where poorly worded questions reveal themselves. If +//! the strongest quarter of the class picked distractor B at a higher rate than +//! the key, B is either the better answer or the stem is ambiguous. That is a +//! different diagnosis from "hard," and it calls for rewriting rather than +//! reteaching. +//! +//! One convention worth stating: a blank response counts as incorrect for +//! difficulty and correlations, because on a scored exam it is worth zero and +//! excluding it would make an item that students skipped look easier than it was. +//! The blank rate is reported separately so a widely skipped item is still visible +//! as such. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::assessment::AssessmentFile; +use crate::catalog::Catalog; +use crate::item::{Design, OptionStat}; +use crate::responses::ResponseSet; +use crate::taxonomy::Flag; + +/// Cut points for flagging items. +#[derive(Debug, Clone)] +pub struct Thresholds { + /// A p-value above this is "too easy". + pub too_easy: f64, + /// A p-value below this is "too hard", but only when discrimination is also + /// poor: a hard item that separates students is doing its job. + pub too_hard: f64, + /// A point-biserial below this is weak discrimination. + pub low_discrimination: f64, + /// A point-biserial below this is treated as genuinely *negative*, which is + /// the blocking "check your key" finding. + /// + /// This is not zero, and the gap matters. In a class of twenty-four the + /// standard error of a point-biserial is around 0.2, so an observed -0.004 is + /// indistinguishable from no relationship at all. Alarming on it would send + /// you hunting for a keying error that is not there, and after a few false + /// alarms the flag stops being read. Values in the dead zone are reported as + /// low discrimination instead, which is what they actually are. + pub negative_discrimination: f64, + /// A selection rate at or below this makes a distractor nonfunctioning. + pub nonfunctioning: f64, + /// How far observed difficulty may drift from the authored expectation. + pub design_tolerance: f64, + /// Fraction of the class in the upper and lower comparison groups. Kelley's + /// 0.27 maximizes the difference between the groups for a normal + /// distribution, and it remains the convention. + pub group_fraction: f64, + /// Below this many examinees, statistics are reported with a caution. + pub small_sample: usize, +} + +impl Default for Thresholds { + fn default() -> Thresholds { + Thresholds { + too_easy: 0.95, + too_hard: 0.25, + low_discrimination: 0.15, + negative_discrimination: -0.05, + nonfunctioning: 0.05, + design_tolerance: 0.25, + group_fraction: 0.27, + small_sample: 100, + } + } +} + +/// Statistics for one option. +#[derive(Debug, Clone)] +pub struct OptionAnalysis { + /// The option letter. + pub letter: String, + /// How many students chose it. + pub count: usize, + /// Fraction of responses that chose it. + pub rate: f64, + /// Correlation between choosing this option and total score on other items. + /// Should be strongly positive for the key and negative for distractors. + pub point_biserial: Option, + /// Selection rate in the upper group. + pub upper_rate: Option, + /// Selection rate in the lower group. + pub lower_rate: Option, + /// Whether this option is keyed. + pub is_key: bool, + /// Mean credit awarded to students who chose it, which exposes partial credit + /// granted during grading. + pub mean_credit: f64, +} + +impl OptionAnalysis { + /// Converts to the form stored on an item's calibration block. + pub fn to_option_stat(&self) -> OptionStat { + OptionStat { + selection_rate: Some(self.rate), + point_biserial: self.point_biserial, + upper_group_rate: self.upper_rate, + lower_group_rate: self.lower_rate, + } + } +} + +/// Statistics for one item. +#[derive(Debug, Clone)] +pub struct ItemAnalysis { + /// The question number on the form. + pub number: u32, + /// The item's global id, when known. + pub item_ref: Option, + /// How many students the item was administered to. + pub n: usize, + /// How many gave a non-blank response. + pub n_answered: usize, + /// Fraction blank. + pub blank_rate: f64, + /// Proportion correct, counting blanks as incorrect. + pub p_value: f64, + /// Mean credit earned, which differs from `p_value` when partial credit was + /// awarded. + pub mean_credit: f64, + /// Corrected item-total point-biserial. `None` when the item has no variance, + /// which happens whenever everyone answered the same way. + pub point_biserial: Option, + /// Upper-group minus lower-group proportion correct. + pub discrimination_index: Option, + /// Proportion correct in the upper group. + pub upper_rate: Option, + /// Proportion correct in the lower group. + pub lower_rate: Option, + /// The keyed letters. + pub key: Vec, + /// Per-option statistics, by letter. + pub options: BTreeMap, + /// Machine-detected problems. + pub flags: Vec, + /// Human-readable explanations tied to the flags. + pub notes: Vec, +} + +impl ItemAnalysis { + /// Whether the item needs attention before reuse. + pub fn needs_revision(&self) -> bool { + self.flags.iter().any(|f| f.is_blocking()) + } + + /// The item's most serious flag, for sorting a work queue. + pub fn worst_flag(&self) -> Option { + self.flags + .iter() + .copied() + .find(|f| f.is_blocking()) + .or_else(|| self.flags.first().copied()) + } +} + +/// Whole-test reliability. +#[derive(Debug, Clone)] +pub struct Reliability { + /// Number of scored items. + pub n_items: usize, + /// Number of examinees. + pub n_students: usize, + /// Mean total score, in items correct. + pub mean: f64, + /// Standard deviation of total score. + pub sd: f64, + /// KR-20, which is Cronbach's alpha for dichotomous items. `None` when there + /// is too little variance to compute it. + pub alpha: Option, + /// Standard error of measurement, in the same units as the total score. + pub sem: Option, + /// Mean p-value across items. + pub mean_p: f64, + /// Mean point-biserial across items that had one. + pub mean_point_biserial: Option, +} + +impl Reliability { + /// A plain-language reading of the alpha value. + /// + /// Interpretation bands are worth printing because alpha is routinely + /// over-read: it depends on test length as much as item quality, so a + /// thirty-item classroom exam at 0.6 is unremarkable, not broken. + /// + /// # Returns + /// + /// A sentence about what the value means for this test. + pub fn interpretation(&self) -> String { + let Some(alpha) = self.alpha else { + return "Reliability could not be computed: there is not enough variation in total \ + scores." + .to_string(); + }; + let band = if alpha >= 0.9 { + "very high, typical of a long standardized test" + } else if alpha >= 0.8 { + "high" + } else if alpha >= 0.7 { + "acceptable for a classroom exam" + } else if alpha >= 0.6 { + "modest, which is common for a short exam in a small class" + } else if alpha >= 0.5 { + "low; treat individual student scores as rough" + } else { + "very low; the total score is not measuring one thing consistently" + }; + let mut s = format!("KR-20 is {alpha:.2} ({band})."); + if let Some(sem) = self.sem { + s.push_str(&format!( + " The standard error of measurement is {sem:.1} items, so a student's true score \ + is roughly within ±{:.0} items of what they scored.", + sem * 1.96 + )); + } + if self.n_items < 20 { + s.push_str( + " Alpha rises with test length, so a short test understates how well its items \ + work.", + ); + } + s + } +} + +/// The result of analyzing one administration. +#[derive(Debug, Clone)] +pub struct Analysis { + /// Per-item statistics, in question order. + pub items: Vec, + /// Whole-test reliability. + pub reliability: Reliability, + /// Cautions about the analysis itself. + pub warnings: Vec, +} + +impl Analysis { + /// Items that need revision, worst first. + /// + /// # Returns + /// + /// References to the flagged items, blocking flags before advisory ones. + pub fn revise_queue(&self) -> Vec<&ItemAnalysis> { + let mut out: Vec<&ItemAnalysis> = + self.items.iter().filter(|i| !i.flags.is_empty()).collect(); + out.sort_by(|a, b| { + b.needs_revision() + .cmp(&a.needs_revision()) + .then_with(|| { + a.point_biserial + .unwrap_or(1.0) + .partial_cmp(&b.point_biserial.unwrap_or(1.0)) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .then_with(|| a.number.cmp(&b.number)) + }); + out + } +} + +/// Runs item analysis over one administration. +/// +/// # Arguments +/// +/// * `set` - the responses. Only scored, undropped items are analyzed. +/// * `t` - flag thresholds. +/// * `record` - the assessment record, for keys and item references. +/// * `catalog` - the loaded course, for authored expectations. +/// +/// # Returns +/// +/// The analysis, including cautions when the sample is too small to trust. +pub fn analyze( + set: &ResponseSet, + t: &Thresholds, + record: Option<&AssessmentFile>, + catalog: Option<&Catalog>, +) -> Analysis { + let matrix = set.matrix(false); + let mut warnings = Vec::new(); + + if !matrix.is_analyzable() { + return Analysis { + items: Vec::new(), + reliability: Reliability { + n_items: 0, + n_students: 0, + mean: 0.0, + sd: 0.0, + alpha: None, + sem: None, + mean_p: 0.0, + mean_point_biserial: None, + }, + warnings: vec![ + "there are no scored responses to analyze; check that ingest matched the \ + assessment record" + .to_string(), + ], + }; + } + + let n_students = matrix.n_students(); + if n_students < t.small_sample { + warnings.push(format!( + "these statistics come from {n_students} examinees. Point-biserials from a class this \ + size have a standard error near {:.2}, so treat anything between -0.2 and 0.2 as \ + indistinguishable from zero and pool several administrations before retiring an item", + 1.0 / ((n_students as f64 - 1.0).max(1.0)).sqrt() + )); + } + + // Blanks count as incorrect for scoring purposes. + let coded: Vec> = matrix + .coded + .iter() + .map(|row| row.iter().map(|c| c.unwrap_or(0) as f64).collect()) + .collect(); + let totals: Vec = coded.iter().map(|row| row.iter().sum()).collect(); + + // Upper and lower groups by total score, Kelley's fraction. + let group_size = ((n_students as f64 * t.group_fraction).round() as usize).max(1); + let mut order: Vec = (0..n_students).collect(); + order.sort_by(|a, b| { + totals[*b] + .partial_cmp(&totals[*a]) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| matrix.students[*a].cmp(&matrix.students[*b])) + }); + let upper: BTreeSet = order.iter().take(group_size).copied().collect(); + let lower: BTreeSet = order.iter().rev().take(group_size).copied().collect(); + let groups_usable = n_students >= 6 && upper.is_disjoint(&lower); + if !groups_usable && n_students > 0 { + warnings.push(format!( + "with {n_students} examinees the upper and lower comparison groups would overlap, so \ + the discrimination index is omitted; the point-biserial uses the whole class and is \ + reported instead" + )); + } + + let mut items = Vec::new(); + let mut p_values = Vec::new(); + let mut rpbs = Vec::new(); + + for (j, number) in matrix.items.iter().enumerate() { + let rows = set.for_item(*number); + let x: Vec = coded.iter().map(|r| r[j]).collect(); + let rest: Vec = totals + .iter() + .zip(x.iter()) + .map(|(total, xi)| total - xi) + .collect(); + + let n = matrix.n_students(); + let n_answered = matrix.coded.iter().filter(|r| r[j].is_some()).count(); + let p_value = mean(&x); + let rpb = correlation(&x, &rest); + + let (upper_rate, lower_rate, discrimination_index) = if groups_usable { + let u = mean_of(&x, &upper); + let l = mean_of(&x, &lower); + (Some(u), Some(l), Some(u - l)) + } else { + (None, None, None) + }; + + // Keys: prefer the record, fall back to what the data says earned credit. + let key: Vec = record + .and_then(|r| r.placement(*number)) + .map(|p| p.key.clone()) + .filter(|k| !k.is_empty()) + .unwrap_or_else(|| infer_key(&rows)); + + let mean_credit = if rows.is_empty() { + 0.0 + } else { + rows.iter().map(|r| r.credit).sum::() / rows.len() as f64 + }; + + // Per-option statistics. + let student_index: BTreeMap<&str, usize> = matrix + .students + .iter() + .enumerate() + .map(|(i, s)| (s.as_str(), i)) + .collect(); + let mut chose: BTreeMap> = BTreeMap::new(); + let mut credits: BTreeMap> = BTreeMap::new(); + let mut blank = 0usize; + for r in &rows { + if r.selected.is_empty() { + blank += 1; + continue; + } + // A multiple-response item is credited to the joined set, so that + // "chose A and C" is one response pattern rather than two options. + let label = r.selected.join("+"); + if let Some(&si) = student_index.get(r.student_key.as_str()) { + chose.entry(label.clone()).or_default().push(si); + } + credits.entry(label).or_default().push(r.credit); + } + + // Every declared option appears, even one nobody chose: a rate of zero is + // the finding. + let mut letters: BTreeSet = chose.keys().cloned().collect(); + if let (Some(rec), Some(cat)) = (record, catalog) { + if let Some(p) = rec.placement(*number) { + if let Some(entry) = cat.get(&p.item) { + for o in &entry.item.options { + letters.insert(o.id.clone()); + } + } + } + } + + let responded = rows.len().max(1); + let mut options = BTreeMap::new(); + for letter in letters { + let indices = chose.get(&letter).cloned().unwrap_or_default(); + let count = indices.len(); + let indicator: Vec = (0..n) + .map(|i| if indices.contains(&i) { 1.0 } else { 0.0 }) + .collect(); + let set_indices: BTreeSet = indices.iter().copied().collect(); + let cr = credits.get(&letter).cloned().unwrap_or_default(); + options.insert( + letter.clone(), + OptionAnalysis { + letter: letter.clone(), + count, + rate: count as f64 / responded as f64, + point_biserial: correlation(&indicator, &rest), + upper_rate: if groups_usable { + Some(mean_of(&indicator, &upper)) + } else { + None + }, + lower_rate: if groups_usable { + Some(mean_of(&indicator, &lower)) + } else { + None + }, + is_key: key.contains(&letter) || (key.len() > 1 && letter == key.join("+")), + mean_credit: if cr.is_empty() { + 0.0 + } else { + cr.iter().sum::() / cr.len() as f64 + }, + }, + ); + let _ = set_indices; + } + + let design = record + .and_then(|r| r.placement(*number)) + .and_then(|p| catalog.and_then(|c| c.get(&p.item))) + .and_then(|e| e.item.design.clone()); + + let mut analysis = ItemAnalysis { + number: *number, + item_ref: record + .and_then(|r| r.placement(*number)) + .map(|p| p.item.clone()), + n, + n_answered, + blank_rate: blank as f64 / responded as f64, + p_value, + mean_credit, + point_biserial: rpb, + discrimination_index, + upper_rate, + lower_rate, + key, + options, + flags: Vec::new(), + notes: Vec::new(), + }; + + flag_item(&mut analysis, t, design.as_ref(), &rows); + + p_values.push(p_value); + if let Some(r) = rpb { + rpbs.push(r); + } + items.push(analysis); + } + + let reliability = reliability(&coded, &totals, &p_values, &rpbs); + + Analysis { + items, + reliability, + warnings, + } +} + +/// Applies the flag rules to one item. +/// +/// # Arguments +/// +/// * `a` - the item analysis, updated in place. +/// * `t` - the thresholds. +/// * `design` - the authored expectation, when available. +/// * `rows` - the raw responses, for partial-credit detection. +fn flag_item( + a: &mut ItemAnalysis, + t: &Thresholds, + design: Option<&Design>, + rows: &[&crate::responses::Response], +) { + // Discrimination first: it is the finding that changes what you do. + match a.point_biserial { + Some(r) if r < t.negative_discrimination => { + a.flags.push(Flag::NegativeDiscrimination); + a.notes.push(format!( + "students who did better overall did worse on this item (r = {r:.2}). Check the \ + key before anything else." + )); + } + Some(r) if r < t.low_discrimination => { + a.flags.push(Flag::LowDiscrimination); + a.notes.push(if r < 0.0 { + format!( + "this item did not separate stronger from weaker students (r = {r:.2}, which \ + is indistinguishable from zero at this sample size)." + ) + } else { + format!("this item barely separates stronger from weaker students (r = {r:.2}).") + }); + } + None => { + a.notes.push( + "every student responded the same way, so this item has no variance and no \ + correlation can be computed." + .to_string(), + ); + } + _ => {} + } + + if a.p_value > t.too_easy { + a.flags.push(Flag::TooEasy); + a.notes.push(format!( + "{:.0}% answered correctly. Fine as an opening anchor, but it carries little \ + information about who knows what.", + a.p_value * 100.0 + )); + } + if a.p_value < t.too_hard { + // Hard and discriminating is a good item, not a broken one. + let discriminates = a + .point_biserial + .map(|r| r >= t.low_discrimination) + .unwrap_or(false); + if !discriminates { + a.flags.push(Flag::TooHard); + a.notes.push(format!( + "only {:.0}% answered correctly, and the item did not separate students. That \ + pattern usually means the stem is unclear or a prerequisite is missing, not that \ + the content is hard.", + a.p_value * 100.0 + )); + } + } + + // Distractor analysis: the real source of "poorly worded question" findings. + let key_rpb = a + .options + .values() + .filter(|o| o.is_key) + .filter_map(|o| o.point_biserial) + .fold(f64::NEG_INFINITY, f64::max); + + for o in a.options.values() { + if o.is_key { + continue; + } + if let Some(r) = o.point_biserial { + if key_rpb.is_finite() && r > key_rpb && o.rate >= 0.1 { + a.flags.push(Flag::DistractorOutperformsKey); + a.notes.push(format!( + "option {} correlates with overall performance better than the key does \ + (r = {r:.2} against {key_rpb:.2}), and {:.0}% chose it. Either it is the \ + better answer or the stem admits it.", + o.letter, + o.rate * 100.0 + )); + } + } + if let (Some(upper), Some(key_upper)) = ( + o.upper_rate, + a.options + .values() + .filter(|k| k.is_key) + .filter_map(|k| k.upper_rate) + .fold(None, |acc: Option, v| { + Some(acc.map_or(v, |a| a.max(v))) + }), + ) { + if upper > key_upper && upper >= 0.25 { + a.flags.push(Flag::KeyUnderperforms); + a.notes.push(format!( + "the strongest students chose option {} more often than the key ({:.0}% \ + against {:.0}%). That split is the signature of two defensible readings.", + o.letter, + upper * 100.0, + key_upper * 100.0 + )); + } + } + if o.rate <= t.nonfunctioning { + a.flags.push(Flag::NonfunctioningDistractor); + a.notes.push(format!( + "option {} was chosen by {:.0}% of students, so it is not doing any work. \ + Replace it with a plausible error students actually make.", + o.letter, + o.rate * 100.0 + )); + } + } + + // Partial credit awarded to a non-key option is a grading-time admission of + // ambiguity, and it is the strongest such signal available. + let ambiguous = a + .options + .values() + .any(|o| !o.is_key && o.mean_credit > 0.0 && o.count > 0); + if ambiguous { + a.flags.push(Flag::Ambiguous); + let letters: Vec = a + .options + .values() + .filter(|o| !o.is_key && o.mean_credit > 0.0 && o.count > 0) + .map(|o| format!("{} ({:.0}%)", o.letter, o.mean_credit * 100.0)) + .collect(); + a.notes.push(format!( + "partial credit was awarded at grading time to {}, which records a decision that the \ + item admitted more than one reading. Rewrite the stem rather than re-deciding this \ + every term.", + letters.join(", ") + )); + } + + // Rapid guessing, when the platform reported response times. + let times: Vec = rows + .iter() + .filter_map(|r| r.response_time_seconds) + .collect(); + if times.len() >= 5 { + let rapid = times.iter().filter(|t| **t < 5.0).count() as f64 / times.len() as f64; + if rapid > 0.15 { + a.flags.push(Flag::HighRapidGuess); + a.notes.push(format!( + "{:.0}% of responses arrived in under five seconds, which is faster than the stem \ + can be read. That is usually about the item's position on the form or time \ + pressure, not the item.", + rapid * 100.0 + )); + } + } + + // Did the item behave as authored? + if let Some(d) = design { + if let Some(expected) = d.expected_difficulty { + if (expected - a.p_value).abs() > t.design_tolerance { + a.flags.push(Flag::DesignMismatch); + a.notes.push(format!( + "you expected about {:.0}% correct and observed {:.0}%. Worth knowing whether \ + your model of the students or the item is off.", + expected * 100.0, + a.p_value * 100.0 + )); + } + } + if let (Some(band), Some(r)) = (d.expected_discrimination, a.point_biserial) { + let (low, high) = band.expected_band(); + if r < low || r > high { + if !a.flags.contains(&Flag::DesignMismatch) { + a.flags.push(Flag::DesignMismatch); + } + a.notes.push(format!( + "you expected {} discrimination ({low:.2} to {high:.2}) and observed {r:.2}.", + format!("{band:?}").to_lowercase() + )); + } + } + } + + a.flags.sort(); + a.flags.dedup(); +} + +/// Computes whole-test reliability. +/// +/// # Arguments +/// +/// * `coded` - the 0/1 response matrix. +/// * `totals` - per-student totals. +/// * `p_values` - per-item p-values. +/// * `rpbs` - per-item point-biserials that could be computed. +/// +/// # Returns +/// +/// The reliability summary. +fn reliability(coded: &[Vec], totals: &[f64], p_values: &[f64], rpbs: &[f64]) -> Reliability { + let n_students = coded.len(); + let n_items = coded.first().map(|r| r.len()).unwrap_or(0); + // Named distinctly from the `mean` and `sd` helpers: binding `let mean = + // mean(totals)` shadows the function for the rest of the scope, which then + // makes the later `mean(p_values)` a call on an f64. + let mean_total = mean(totals); + let sd_total = sd(totals); + + // KR-20. The variance terms use the population form, which is the convention + // for this coefficient and matches what other packages report. + let alpha = if n_items > 1 && sd_total > 0.0 { + let sum_pq: f64 = p_values.iter().map(|p| p * (1.0 - p)).sum(); + let k = n_items as f64; + let variance = sd_total * sd_total; + Some((k / (k - 1.0)) * (1.0 - sum_pq / variance)) + } else { + None + }; + + let sem = alpha.map(|a| sd_total * (1.0 - a).max(0.0).sqrt()); + + Reliability { + n_items, + n_students, + mean: mean_total, + sd: sd_total, + alpha, + sem, + mean_p: mean(p_values), + mean_point_biserial: if rpbs.is_empty() { + None + } else { + Some(mean(rpbs)) + }, + } +} + +/// Infers the key from which options earned full credit. +/// +/// Used when no assessment record is available, so that an export can be analyzed +/// before its record is written. +/// +/// # Arguments +/// +/// * `rows` - the responses for one item. +/// +/// # Returns +/// +/// The letters that appear on full-credit responses. +fn infer_key(rows: &[&crate::responses::Response]) -> Vec { + let mut out: BTreeSet = BTreeSet::new(); + for r in rows { + if r.credit >= 0.999 { + for letter in &r.selected { + out.insert(letter.clone()); + } + } + } + out.into_iter().collect() +} + +/// The arithmetic mean, zero for an empty slice. +fn mean(v: &[f64]) -> f64 { + if v.is_empty() { + 0.0 + } else { + v.iter().sum::() / v.len() as f64 + } +} + +/// The population standard deviation. +fn sd(v: &[f64]) -> f64 { + if v.len() < 2 { + return 0.0; + } + let m = mean(v); + (v.iter().map(|x| (x - m) * (x - m)).sum::() / v.len() as f64).sqrt() +} + +/// The mean of the entries at the given indices. +fn mean_of(v: &[f64], indices: &BTreeSet) -> f64 { + if indices.is_empty() { + return 0.0; + } + indices.iter().map(|i| v[*i]).sum::() / indices.len() as f64 +} + +/// The Pearson correlation of two equal-length vectors. +/// +/// # Arguments +/// +/// * `x` - the first vector. +/// * `y` - the second vector. +/// +/// # Returns +/// +/// The correlation, or `None` when either vector has no variance. Returning +/// `None` rather than a NaN matters: an item everyone answered correctly has no +/// correlation, and that is a meaningful result to report rather than a number to +/// propagate. +pub fn correlation(x: &[f64], y: &[f64]) -> Option { + if x.len() != y.len() || x.len() < 2 { + return None; + } + let mx = mean(x); + let my = mean(y); + let mut sxy = 0.0; + let mut sxx = 0.0; + let mut syy = 0.0; + for (xi, yi) in x.iter().zip(y.iter()) { + let dx = xi - mx; + let dy = yi - my; + sxy += dx * dy; + sxx += dx * dx; + syy += dy * dy; + } + if sxx <= f64::EPSILON || syy <= f64::EPSILON { + return None; + } + Some(sxy / (sxx * syy).sqrt()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::responses::Response; + + fn resp(student: &str, number: u32, letter: &str, credit: f64) -> Response { + Response { + administration_id: "C/T/a".into(), + course: "C".into(), + term: "T".into(), + assessment_id: "a".into(), + date: None, + form: None, + student_key: student.into(), + sid: None, + name: None, + email: None, + section: None, + item_number: number, + item_ref: None, + item_version: None, + selected: if letter.is_empty() { + vec![] + } else { + vec![letter.to_string()] + }, + eliminated: vec![], + correct: Some(credit >= 0.999), + credit, + points_possible: 1.0, + score: credit, + response_time_seconds: None, + level: None, + learning_objectives: vec![], + topics: vec![], + bonus: false, + dropped: false, + } + } + + /// Twelve students; item 1 discriminates, item 2 is keyed backwards. + /// + /// Item 2 is only *partly* reversed, and that is load-bearing rather than + /// sloppy. The corrected point-biserial scores each item against the total of + /// the *other* items, and with four items — one of them unanimous — item 2 is + /// most of item 1's rest score. Make item 2 an exact complement of item 1 and + /// `item1 + item2 == 1` for every student, so the total collapses to + /// `2 + item4`, carries no ability signal at all, and both items come out at + /// r = -0.71. The fixture then contradicts itself: the item it calls good is + /// flagged for negative discrimination, and the reversed key is negative only + /// because it mirrors item 1 rather than because it is miskeyed. + fn sample() -> ResponseSet { + let mut set = ResponseSet::new(); + for i in 0..12 { + let strong = i < 6; + let s = format!("s{i:02}"); + // Item 1: strong students right, weak wrong. + set.rows.push(resp( + &s, + 1, + if strong { "A" } else { "B" }, + if strong { 1.0 } else { 0.0 }, + )); + // Item 2: weaker students do better on it, which is what a keying + // error looks like. Half the strong group and two thirds of the weak + // group get it, so it is reversed without mirroring item 1. + let missed = if strong { i < 3 } else { i < 8 }; + set.rows.push(resp( + &s, + 2, + if missed { "C" } else { "D" }, + if missed { 0.0 } else { 1.0 }, + )); + // Item 3: everyone correct. + set.rows.push(resp(&s, 3, "A", 1.0)); + // Item 4: a second discriminating item, so that removing any one item + // still leaves a rest score that tracks ability. + set.rows.push(resp( + &s, + 4, + if strong { "A" } else { "B" }, + if strong { 1.0 } else { 0.0 }, + )); + } + set + } + + #[test] + fn correlation_returns_none_without_variance() { + assert_eq!(correlation(&[1.0, 1.0, 1.0], &[1.0, 2.0, 3.0]), None); + assert_eq!(correlation(&[1.0], &[1.0]), None); + let r = correlation(&[1.0, 2.0, 3.0], &[2.0, 4.0, 6.0]).unwrap(); + assert!((r - 1.0).abs() < 1e-12, "perfect correlation is 1, got {r}"); + let r = correlation(&[1.0, 2.0, 3.0], &[3.0, 2.0, 1.0]).unwrap(); + assert!((r + 1.0).abs() < 1e-12); + } + + #[test] + fn flags_a_reversed_key_as_negative_discrimination() { + let set = sample(); + let a = analyze(&set, &Thresholds::default(), None, None); + let item2 = a.items.iter().find(|i| i.number == 2).unwrap(); + assert!( + item2.point_biserial.unwrap() < 0.0, + "got {:?}", + item2.point_biserial + ); + assert!(item2.flags.contains(&Flag::NegativeDiscrimination)); + assert!(item2.needs_revision()); + } + + #[test] + fn a_good_item_is_not_flagged_for_discrimination() { + let set = sample(); + let a = analyze(&set, &Thresholds::default(), None, None); + let item1 = a.items.iter().find(|i| i.number == 1).unwrap(); + assert!(item1.point_biserial.unwrap() > 0.5); + assert!(!item1.flags.contains(&Flag::NegativeDiscrimination)); + assert!(!item1.flags.contains(&Flag::LowDiscrimination)); + } + + #[test] + fn a_unanimous_item_has_no_correlation_and_is_flagged_easy() { + let set = sample(); + let a = analyze(&set, &Thresholds::default(), None, None); + let item3 = a.items.iter().find(|i| i.number == 3).unwrap(); + assert_eq!(item3.p_value, 1.0); + assert_eq!(item3.point_biserial, None, "no variance, so no correlation"); + assert!(item3.flags.contains(&Flag::TooEasy)); + } + + #[test] + fn blank_responses_count_as_incorrect_but_are_reported() { + let mut set = ResponseSet::new(); + for i in 0..10 { + let s = format!("s{i}"); + let letter = if i < 5 { "A" } else { "" }; + set.rows + .push(resp(&s, 1, letter, if i < 5 { 1.0 } else { 0.0 })); + set.rows + .push(resp(&s, 2, "A", if i < 7 { 1.0 } else { 0.0 })); + } + let a = analyze(&set, &Thresholds::default(), None, None); + let item1 = a.items.iter().find(|i| i.number == 1).unwrap(); + assert_eq!(item1.p_value, 0.5); + assert_eq!(item1.blank_rate, 0.5); + assert_eq!( + item1.n_answered, 10, + "a blank is still an administered item" + ); + } + + #[test] + fn partial_credit_to_a_distractor_flags_ambiguity() { + let mut set = ResponseSet::new(); + for i in 0..10 { + let s = format!("s{i}"); + if i < 5 { + set.rows.push(resp(&s, 1, "A", 1.0)); + } else { + // B earned two thirds of the points at grading time. + set.rows.push(resp(&s, 1, "B", 0.667)); + } + set.rows + .push(resp(&s, 2, "A", if i % 2 == 0 { 1.0 } else { 0.0 })); + } + let a = analyze(&set, &Thresholds::default(), None, None); + let item1 = a.items.iter().find(|i| i.number == 1).unwrap(); + assert!(item1.flags.contains(&Flag::Ambiguous), "{:?}", item1.flags); + assert!(item1.notes.iter().any(|n| n.contains("partial credit"))); + } + + #[test] + fn reliability_is_computed_and_explained() { + let set = sample(); + let a = analyze(&set, &Thresholds::default(), None, None); + assert_eq!(a.reliability.n_items, 4); + assert_eq!(a.reliability.n_students, 12); + assert!(a.reliability.alpha.is_some()); + let text = a.reliability.interpretation(); + assert!(text.contains("KR-20")); + // A four-item test must carry the length caveat. + assert!(text.contains("test length")); + } + + #[test] + fn small_samples_get_a_caution() { + let set = sample(); + let a = analyze(&set, &Thresholds::default(), None, None); + assert!(a.warnings.iter().any(|w| w.contains("examinees"))); + } + + #[test] + fn empty_input_does_not_panic() { + let a = analyze(&ResponseSet::new(), &Thresholds::default(), None, None); + assert!(a.items.is_empty()); + assert_eq!(a.reliability.alpha, None); + assert!(!a.warnings.is_empty()); + } + + #[test] + fn the_revise_queue_puts_blocking_flags_first() { + let set = sample(); + let a = analyze(&set, &Thresholds::default(), None, None); + let queue = a.revise_queue(); + assert!(!queue.is_empty()); + assert_eq!(queue[0].number, 2, "the reversed key comes first"); + } +} diff --git a/src/analysis/irt.rs b/src/analysis/irt.rs new file mode 100644 index 0000000..5a30bda --- /dev/null +++ b/src/analysis/irt.rs @@ -0,0 +1,1176 @@ +// SPDX-License-Identifier: Prosperity-3.0.0 +// Copyright Scientific Computing Studio +// Source: https://git.scient.ing/education/coursebank + +//! Item response theory: marginal maximum likelihood estimation by EM. +//! +//! The estimator here was prototyped and validated against a real 24-examinee, +//! 30-item exam before being written in Rust. On that data it converges in 31 EM +//! iterations, and the resulting abilities correlate 0.994 with total score — +//! which is the sanity check that matters, because if IRT abilities did *not* +//! track total score on a short unidimensional test, something would be wrong. +//! +//! # Why priors are on by default +//! +//! Classroom exams break unpenalized maximum likelihood routinely. Any item that +//! everyone answered correctly has no finite maximum: the likelihood increases +//! without bound as difficulty goes to negative infinity, and the optimizer walks +//! off to whatever bound you set. The real exam this was built against had two +//! such items. +//! +//! A weakly informative prior fixes this properly rather than by clamping. With +//! `a ~ lognormal(0, 0.5)` and `b ~ Normal(0, 2)`, those two items settled at +//! a = 1.26, b = −3.08: very easy, moderately discriminating, which is an honest +//! description of an item everyone got right. The priors are weak enough that +//! items with real information in them are essentially unaffected. +//! +//! Priors can be turned off, and doing so is legitimate for a large pooled +//! dataset. On a single small class it produces divergence, not objectivity. +//! +//! # What sample size buys you +//! +//! Rasch needs the fewest examinees, since it estimates one parameter per item. +//! 2PL wants a few hundred to pin discrimination down; 3PL wants a thousand and +//! is not honestly estimable from one section of twenty-five, which is why asking +//! for it produces a warning rather than a refusal. The parameters are still +//! useful for ranking items and for computing abilities that respect item +//! difficulty. They are not useful as absolute values to publish. + +use std::collections::BTreeMap; + +use crate::item::{IrtModel, IrtParams}; +use crate::responses::Matrix; + +/// The number of quadrature points. +/// +/// Forty-one points over ±4 is far more than a short test needs; the cost is +/// trivial at this scale and it removes quadrature coarseness as a possible +/// explanation for anything surprising in the output. +const QUAD_POINTS: usize = 41; +/// The lower bound of the ability grid. +const QUAD_LO: f64 = -4.0; +/// The upper bound of the ability grid. +const QUAD_HI: f64 = 4.0; + +/// Smallest allowed discrimination, to keep the Newton step in a sane region. +const A_MIN: f64 = 0.02; +/// Largest allowed discrimination. +const A_MAX: f64 = 4.0; +/// Largest allowed absolute difficulty. +const B_MAX: f64 = 8.0; + +/// Weakly informative priors on the item parameters. +#[derive(Debug, Clone)] +pub struct Priors { + /// Whether to use priors at all. + pub enabled: bool, + /// Mean of `log(a)`. + pub mu_log_a: f64, + /// Standard deviation of `log(a)`. + pub sd_log_a: f64, + /// Mean of `b`. + pub mu_b: f64, + /// Standard deviation of `b`. + pub sd_b: f64, +} + +impl Default for Priors { + fn default() -> Priors { + Priors { + enabled: true, + mu_log_a: 0.0, + sd_log_a: 0.5, + mu_b: 0.0, + sd_b: 2.0, + } + } +} + +/// Estimation settings. +#[derive(Debug, Clone)] +pub struct Options { + /// Which model to fit. + pub model: IrtModel, + /// The priors. + pub priors: Priors, + /// Maximum EM iterations. + pub max_iterations: usize, + /// Convergence tolerance on the largest parameter change. + pub tolerance: f64, + /// Guessing values to search over for the 3PL, as a lower asymptote. + pub guess_grid: Vec, +} + +impl Default for Options { + fn default() -> Options { + Options { + model: IrtModel::TwoPl, + priors: Priors::default(), + max_iterations: 200, + tolerance: 1e-5, + // A five-option item has a chance floor near 0.2; above 0.4 the + // parameter stops being "guessing" and starts absorbing real + // misfit. + guess_grid: vec![0.0, 0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40], + } + } +} + +/// One item's estimated parameters. +#[derive(Debug, Clone)] +pub struct ItemFit { + /// The question number. + pub number: u32, + /// Discrimination. + pub a: f64, + /// Difficulty, on the ability scale. + pub b: f64, + /// Lower asymptote, for the 3PL. + pub c: Option, + /// Standard error of `a`, when the information matrix was invertible. + pub se_a: Option, + /// Standard error of `b`. + pub se_b: Option, + /// How many examinees contributed. + pub n: usize, + /// Whether a prior was applied. + pub bayesian: bool, + /// The model fitted. + pub model: IrtModel, + /// Notes about this item's estimation, such as a parameter pinned by its + /// prior because the data alone did not identify it. + pub notes: Vec, +} + +impl ItemFit { + /// Converts to the form stored on an item's calibration block. + /// + /// # Arguments + /// + /// * `n` - the examinee count to record. + /// + /// # Returns + /// + /// The parameters. + pub fn to_params(&self) -> IrtParams { + IrtParams { + model: self.model, + a: round6(self.a), + b: round6(self.b), + c: self.c.map(round6), + se_a: self.se_a.map(round6), + se_b: self.se_b.map(round6), + n: Some(self.n), + bayesian: self.bayesian, + } + } + + /// The probability a student of a given ability answers correctly. + /// + /// # Arguments + /// + /// * `theta` - the ability. + /// + /// # Returns + /// + /// The probability. + pub fn probability(&self, theta: f64) -> f64 { + let c = self.c.unwrap_or(0.0); + c + (1.0 - c) * logistic(self.a * (theta - self.b)) + } + + /// Fisher information at a given ability. + /// + /// Where an item is informative is the practical question when choosing items: + /// an item is most useful for distinguishing students whose ability is near + /// its difficulty. + /// + /// # Arguments + /// + /// * `theta` - the ability. + /// + /// # Returns + /// + /// The information. + pub fn information(&self, theta: f64) -> f64 { + let p = self.probability(theta); + let c = self.c.unwrap_or(0.0); + if p <= 0.0 || p >= 1.0 || c >= 1.0 { + return 0.0; + } + // For the 3PL this reduces to the 2PL form when c = 0. + let num = self.a * self.a * (p - c) * (p - c) * (1.0 - p); + let den = (1.0 - c) * (1.0 - c) * p; + if den <= 0.0 { 0.0 } else { num / den } + } +} + +/// One examinee's estimated ability. +#[derive(Debug, Clone)] +pub struct Ability { + /// The student key. + pub student_key: String, + /// Expected a posteriori ability estimate. + pub theta: f64, + /// Posterior standard deviation, which is the standard error of the estimate. + pub se: f64, + /// Number of items answered. + pub n_items: usize, +} + +impl Ability { + /// A plain-language band for the estimate. + /// + /// # Returns + /// + /// A short description, deliberately coarse because the standard error on a + /// thirty-item test is around half a logit and finer distinctions would be + /// noise. + pub fn band(&self) -> &'static str { + if self.theta >= 1.0 { + "well above the class average" + } else if self.theta >= 0.35 { + "above the class average" + } else if self.theta > -0.35 { + "near the class average" + } else if self.theta > -1.0 { + "below the class average" + } else { + "well below the class average" + } + } +} + +/// The result of an estimation run. +#[derive(Debug, Clone)] +pub struct Fit { + /// Per-item parameters, in question order. + pub items: Vec, + /// Per-examinee abilities, in student order. + pub abilities: Vec, + /// EM iterations used. + pub iterations: usize, + /// Whether the tolerance was reached. + pub converged: bool, + /// Marginal log-likelihood at the solution. + pub log_likelihood: f64, + /// Cautions about the fit. + pub warnings: Vec, +} + +impl Fit { + /// Test information at a given ability, summed over items. + /// + /// # Arguments + /// + /// * `theta` - the ability. + /// + /// # Returns + /// + /// The total information, whose reciprocal square root is the standard error + /// of measurement at that ability. + pub fn test_information(&self, theta: f64) -> f64 { + self.items.iter().map(|i| i.information(theta)).sum() + } + + /// Standard error of measurement at a given ability. + /// + /// # Arguments + /// + /// * `theta` - the ability. + /// + /// # Returns + /// + /// The standard error, or `None` where the test carries no information. + pub fn standard_error(&self, theta: f64) -> Option { + let info = self.test_information(theta); + if info <= 0.0 { + None + } else { + Some(1.0 / info.sqrt()) + } + } + + /// Where the test measures best, on a coarse grid. + /// + /// Worth reporting because a test can be reliable overall while measuring + /// almost nothing about the students you most need to distinguish. + /// + /// # Returns + /// + /// The ability with the most information. + pub fn peak_information(&self) -> f64 { + let mut best = (-4.0, f64::NEG_INFINITY); + let mut theta = -4.0; + while theta <= 4.0 { + let info = self.test_information(theta); + if info > best.1 { + best = (theta, info); + } + theta += 0.1; + } + best.0 + } + + /// Abilities keyed by student. + pub fn ability_map(&self) -> BTreeMap { + self.abilities + .iter() + .map(|a| (a.student_key.clone(), a.theta)) + .collect() + } +} + +/// The logistic function, guarded against overflow. +/// +/// # Arguments +/// +/// * `z` - the linear predictor. +/// +/// # Returns +/// +/// The probability. +fn logistic(z: f64) -> f64 { + if z < -40.0 { + 0.0 + } else if z > 40.0 { + 1.0 + } else { + 1.0 / (1.0 + (-z).exp()) + } +} + +/// Rounds to six decimals, so YAML output does not carry meaningless precision. +fn round6(x: f64) -> f64 { + (x * 1e6).round() / 1e6 +} + +/// The quadrature grid: equally spaced points with standard normal weights. +/// +/// # Returns +/// +/// The points and their normalized weights. +fn quadrature() -> (Vec, Vec) { + let mut theta = Vec::with_capacity(QUAD_POINTS); + let mut weight = Vec::with_capacity(QUAD_POINTS); + for k in 0..QUAD_POINTS { + let t = QUAD_LO + (QUAD_HI - QUAD_LO) * k as f64 / (QUAD_POINTS - 1) as f64; + theta.push(t); + weight.push((-0.5 * t * t).exp()); + } + let total: f64 = weight.iter().sum(); + for w in weight.iter_mut() { + *w /= total; + } + (theta, weight) +} + +/// Fits an IRT model to a response matrix. +/// +/// # Arguments +/// +/// * `matrix` - the dichotomous response matrix. Missing responses are treated as +/// not administered rather than incorrect, which is the correct handling for a +/// likelihood: a student who never saw an item tells you nothing about it. +/// * `opts` - estimation settings. +/// +/// # Returns +/// +/// The fit, with warnings about sample size and any item whose parameters were +/// determined by its prior rather than by the data. +pub fn fit(matrix: &Matrix, opts: &Options) -> Fit { + let n = matrix.n_students(); + let j_count = matrix.n_items(); + let mut warnings = Vec::new(); + + if n == 0 || j_count == 0 { + return Fit { + items: Vec::new(), + abilities: Vec::new(), + iterations: 0, + converged: false, + log_likelihood: f64::NAN, + warnings: vec!["there is nothing to fit: the response matrix is empty".to_string()], + }; + } + + let needed = match opts.model { + IrtModel::Rasch => 100, + IrtModel::TwoPl => 200, + IrtModel::ThreePl => 1000, + }; + if n < needed { + warnings.push(format!( + "fitting {} to {n} examinees. This model usually wants {needed} or more, so treat the \ + parameters as a ranking of items rather than as absolute values, and pool several \ + administrations before acting on any single number", + model_name(opts.model) + )); + } + if !opts.priors.enabled { + warnings.push( + "priors are disabled. Any item answered the same way by every examinee has no finite \ + maximum likelihood estimate, and its difficulty will be pinned at the bound instead \ + of estimated" + .to_string(), + ); + } + + let (grid, base_weight) = quadrature(); + let n_quad = grid.len(); + + // Starting values. Difficulty from the p-value is a much better start than + // zero and saves several EM iterations. + let mut a = vec![1.0f64; j_count]; + let mut b = vec![0.0f64; j_count]; + let mut c = vec![0.0f64; j_count]; + for (j, b_j) in b.iter_mut().enumerate() { + let column = matrix.column(j); + let answered: Vec = column.into_iter().flatten().collect(); + if answered.is_empty() { + continue; + } + let p = answered.iter().map(|v| *v as f64).sum::() / answered.len() as f64; + let clamped = p.clamp(0.03, 0.97); + // Inverse logistic of the p-value, which is the difficulty a Rasch model + // implies when discrimination is one. + *b_j = -(clamped / (1.0 - clamped)).ln(); + } + + let mut iterations = 0usize; + let mut converged = false; + let mut notes: Vec> = vec![Vec::new(); j_count]; + + for iteration in 0..opts.max_iterations { + iterations = iteration + 1; + + // ---- E step: expected counts at each quadrature point ---- + // Counts are accumulated per item rather than globally, so an item + // administered to only some examinees is not charged for the others. + let mut n_kj = vec![vec![0.0f64; j_count]; n_quad]; + let mut r_k = vec![vec![0.0f64; j_count]; n_quad]; + + // Response probabilities on the grid, computed once per iteration. + let mut p_grid = vec![vec![0.0f64; j_count]; n_quad]; + for k in 0..n_quad { + for j in 0..j_count { + p_grid[k][j] = c[j] + (1.0 - c[j]) * logistic(a[j] * (grid[k] - b[j])); + } + } + + for i in 0..n { + let posterior = posterior_for(&matrix.coded[i], &p_grid, &base_weight); + for j in 0..j_count { + let Some(u) = matrix.coded[i][j] else { + continue; + }; + for k in 0..n_quad { + n_kj[k][j] += posterior[k]; + if u == 1 { + r_k[k][j] += posterior[k]; + } + } + } + } + + // ---- M step: one two-parameter Newton solve per item ---- + let mut delta = 0.0f64; + for j in 0..j_count { + let counts: Vec<(f64, f64)> = (0..n_quad).map(|k| (n_kj[k][j], r_k[k][j])).collect(); + + let (new_a, new_b, new_c) = match opts.model { + IrtModel::Rasch => { + let nb = newton_rasch(&grid, &counts, b[j], &opts.priors); + (1.0, nb, 0.0) + } + IrtModel::TwoPl => { + let (na, nb) = newton_2pl(&grid, &counts, a[j], b[j], 0.0, &opts.priors); + (na, nb, 0.0) + } + IrtModel::ThreePl => { + // A profile search over the lower asymptote: for each + // candidate, fit a and b, and keep whichever candidate gives + // the best expected complete-data log-likelihood. Estimating + // c jointly with two other parameters from a small sample is + // where 3PL fits go unstable. + let mut best = (a[j], b[j], 0.0, f64::NEG_INFINITY); + for cand in &opts.guess_grid { + let (na, nb) = newton_2pl(&grid, &counts, a[j], b[j], *cand, &opts.priors); + let ll = expected_ll(&grid, &counts, na, nb, *cand); + if ll > best.3 { + best = (na, nb, *cand, ll); + } + } + (best.0, best.1, best.2) + } + }; + + delta = delta + .max((new_a - a[j]).abs()) + .max((new_b - b[j]).abs()) + .max((new_c - c[j]).abs()); + a[j] = new_a; + b[j] = new_b; + c[j] = new_c; + } + + if delta < opts.tolerance { + converged = true; + break; + } + } + + if !converged { + warnings.push(format!( + "estimation stopped at the iteration limit ({}) without meeting the tolerance of \ + {:.0e}. The parameters are usable but not fully settled; a near-degenerate item is \ + the usual cause", + opts.max_iterations, opts.tolerance + )); + } + + // ---- Standard errors and per-item notes ---- + let (grid_final, weight_final) = (grid.clone(), base_weight.clone()); + let mut p_grid = vec![vec![0.0f64; j_count]; n_quad]; + for k in 0..n_quad { + for j in 0..j_count { + p_grid[k][j] = c[j] + (1.0 - c[j]) * logistic(a[j] * (grid_final[k] - b[j])); + } + } + let mut n_kj = vec![vec![0.0f64; j_count]; n_quad]; + let mut r_k = vec![vec![0.0f64; j_count]; n_quad]; + for i in 0..n { + let posterior = posterior_for(&matrix.coded[i], &p_grid, &weight_final); + for j in 0..j_count { + if matrix.coded[i][j].is_some() { + for k in 0..n_quad { + n_kj[k][j] += posterior[k]; + if matrix.coded[i][j] == Some(1) { + r_k[k][j] += posterior[k]; + } + } + } + } + } + + let mut items = Vec::with_capacity(j_count); + for j in 0..j_count { + let counts: Vec<(f64, f64)> = (0..n_quad).map(|k| (n_kj[k][j], r_k[k][j])).collect(); + let (se_a, se_b) = standard_errors(&grid_final, &counts, a[j], b[j], c[j], &opts.priors); + + let column = matrix.column(j); + let answered: Vec = column.into_iter().flatten().collect(); + let n_item = answered.len(); + let all_same = !answered.is_empty() && answered.iter().all(|v| *v == answered[0]); + if all_same { + notes[j].push(format!( + "every examinee answered this item the same way, so its parameters come from the \ + prior rather than from the data; b = {:.2} means only \"outside the range this \ + class could resolve\"", + b[j] + )); + } + if a[j] <= A_MIN + 1e-9 { + notes[j].push( + "discrimination hit its lower bound, which means the responses carry no \ + information about ability ordering" + .to_string(), + ); + } + if b[j].abs() >= B_MAX - 1e-9 { + notes[j].push( + "difficulty hit its bound and should be read as \"off the scale\"".to_string(), + ); + } + + items.push(ItemFit { + number: matrix.items[j], + a: a[j], + b: b[j], + c: if opts.model == IrtModel::ThreePl { + Some(c[j]) + } else { + None + }, + se_a, + se_b, + n: n_item, + bayesian: opts.priors.enabled, + model: opts.model, + notes: notes[j].clone(), + }); + } + + // ---- Abilities, expected a posteriori ---- + let mut abilities = Vec::with_capacity(n); + let mut log_likelihood = 0.0f64; + for i in 0..n { + let (posterior, marginal) = + posterior_and_marginal(&matrix.coded[i], &p_grid, &weight_final); + log_likelihood += marginal; + let theta: f64 = (0..n_quad).map(|k| posterior[k] * grid_final[k]).sum(); + let variance: f64 = (0..n_quad) + .map(|k| posterior[k] * (grid_final[k] - theta) * (grid_final[k] - theta)) + .sum(); + abilities.push(Ability { + student_key: matrix.students[i].clone(), + theta: round6(theta), + se: round6(variance.max(0.0).sqrt()), + n_items: matrix.coded[i].iter().filter(|v| v.is_some()).count(), + }); + } + + Fit { + items, + abilities, + iterations, + converged, + log_likelihood, + warnings, + } +} + +/// The posterior distribution over the ability grid for one examinee. +/// +/// # Arguments +/// +/// * `responses` - the examinee's coded responses; `None` entries are skipped. +/// * `p_grid` - response probabilities by quadrature point and item. +/// * `weight` - the prior weights. +/// +/// # Returns +/// +/// The normalized posterior. +fn posterior_for(responses: &[Option], p_grid: &[Vec], weight: &[f64]) -> Vec { + posterior_and_marginal(responses, p_grid, weight).0 +} + +/// The posterior and the marginal log-likelihood for one examinee. +/// +/// Working in logs and subtracting the maximum before exponentiating is what keeps +/// this stable: with thirty items the raw likelihood at an implausible ability +/// underflows to zero in double precision, and the posterior becomes all NaN. +/// +/// # Arguments +/// +/// * `responses` - the coded responses. +/// * `p_grid` - response probabilities by quadrature point and item. +/// * `weight` - the prior weights. +/// +/// # Returns +/// +/// The normalized posterior and the examinee's marginal log-likelihood. +fn posterior_and_marginal( + responses: &[Option], + p_grid: &[Vec], + weight: &[f64], +) -> (Vec, f64) { + let n_quad = weight.len(); + let mut log_like = vec![0.0f64; n_quad]; + for k in 0..n_quad { + let mut total = 0.0; + for (j, response) in responses.iter().enumerate() { + let Some(u) = response else { continue }; + let p = p_grid[k][j].clamp(1e-12, 1.0 - 1e-12); + total += if *u == 1 { p.ln() } else { (1.0 - p).ln() }; + } + log_like[k] = total; + } + + let max = log_like.iter().copied().fold(f64::NEG_INFINITY, f64::max); + let mut posterior = vec![0.0f64; n_quad]; + let mut sum = 0.0; + for k in 0..n_quad { + posterior[k] = weight[k] * (log_like[k] - max).exp(); + sum += posterior[k]; + } + if sum <= 0.0 || !sum.is_finite() { + // Degenerate: fall back to the prior rather than emitting NaN. + return (weight.to_vec(), f64::NAN); + } + for p in posterior.iter_mut() { + *p /= sum; + } + (posterior, max + sum.ln()) +} + +/// One item's expected complete-data log-likelihood, used to choose `c`. +/// +/// # Arguments +/// +/// * `grid` - the ability grid. +/// * `counts` - expected `(administered, correct)` counts per grid point. +/// * `a` - discrimination. +/// * `b` - difficulty. +/// * `c` - lower asymptote. +/// +/// # Returns +/// +/// The expected log-likelihood. +fn expected_ll(grid: &[f64], counts: &[(f64, f64)], a: f64, b: f64, c: f64) -> f64 { + let mut total = 0.0; + for (k, (n_k, r_k)) in counts.iter().enumerate() { + let p = (c + (1.0 - c) * logistic(a * (grid[k] - b))).clamp(1e-12, 1.0 - 1e-12); + total += r_k * p.ln() + (n_k - r_k) * (1.0 - p).ln(); + } + total +} + +/// The 2PL M-step: a damped two-parameter Newton solve. +/// +/// The gradient and Hessian are analytic. With `W = n·P·(1−P)`, `e = r − n·P`, and +/// `u = θ − b`: +/// +/// ```text +/// ∂L/∂a = Σ e·u ∂L/∂b = −a·Σ e +/// H = [ −Σ W·u² −Σ e + a·Σ W·u ] +/// [ −Σ e + a·Σ W·u −a²·Σ W ] +/// ``` +/// +/// # Arguments +/// +/// * `grid` - the ability grid. +/// * `counts` - expected `(administered, correct)` counts per grid point. +/// * `a0` - starting discrimination. +/// * `b0` - starting difficulty. +/// * `c` - the fixed lower asymptote. +/// * `priors` - the priors to add to the gradient and Hessian. +/// +/// # Returns +/// +/// The updated `(a, b)`. +fn newton_2pl( + grid: &[f64], + counts: &[(f64, f64)], + a0: f64, + b0: f64, + c: f64, + priors: &Priors, +) -> (f64, f64) { + let mut a = a0; + let mut b = b0; + + for _ in 0..30 { + let mut sum_e = 0.0; + let mut sum_w = 0.0; + let mut sum_wu = 0.0; + let mut sum_wu2 = 0.0; + let mut g_a = 0.0; + + for (k, (n_k, r_k)) in counts.iter().enumerate() { + if *n_k <= 0.0 { + continue; + } + let theta = grid[k]; + let u = theta - b; + let p_star = logistic(a * u); + let p = c + (1.0 - c) * p_star; + let p = p.clamp(1e-12, 1.0 - 1e-12); + // With a lower asymptote, the derivative of P with respect to the + // linear predictor carries a (1−c) factor and the residual is scaled + // by (P*−0)/(P−c); for c = 0 this reduces to the plain 2PL form. + let scale = if c > 0.0 { + (1.0 - c) * (p_star * (1.0 - p_star)) / (p * (1.0 - p)) + } else { + 1.0 + }; + let e = (r_k - n_k * p) * scale; + // The information weight is n·(∂P/∂η)²/(P(1−P)) where η is the linear + // predictor. Written as n·scale²·P(1−P) it is exact for any c, and + // reduces to the familiar n·P(1−P) when c = 0. + let w = n_k * scale * scale * p * (1.0 - p); + + sum_e += e; + sum_w += w; + sum_wu += w * u; + sum_wu2 += w * u * u; + g_a += e * u; + } + + let mut g1 = g_a; + let mut g2 = -a * sum_e; + let mut h11 = -sum_wu2; + // Not `mut`: the priors on a and b are independent, so neither contributes a + // cross-derivative term to h12. + let h12 = -sum_e + a * sum_wu; + let mut h22 = -a * a * sum_w; + + if priors.enabled { + // log-normal on a: the log-density in a is + // −log(a) − (log a − μ)² / (2σ²), differentiated twice in a. + let la = a.ln(); + let s2 = priors.sd_log_a * priors.sd_log_a; + g1 += -1.0 / a - (la - priors.mu_log_a) / (s2 * a); + h11 += 1.0 / (a * a) + (la - priors.mu_log_a) / (s2 * a * a) - 1.0 / (s2 * a * a); + // Normal on b. + let t2 = priors.sd_b * priors.sd_b; + g2 += -(b - priors.mu_b) / t2; + h22 += -1.0 / t2; + } + + let det = h11 * h22 - h12 * h12; + if det.abs() < 1e-12 { + break; + } + let da = -(h22 * g1 - h12 * g2) / det; + let db = -(-h12 * g1 + h11 * g2) / det; + + // Damping: halve the step until it stays inside the admissible region. + // Without this a single wild Newton step can throw a near-degenerate item + // to a bound it never recovers from. + let mut step = 1.0; + while step > 1e-3 && (a + step * da <= A_MIN || (b + step * db).abs() > B_MAX) { + step *= 0.5; + } + let new_a = (a + step * da).clamp(A_MIN, A_MAX); + let new_b = (b + step * db).clamp(-B_MAX, B_MAX); + let moved = (new_a - a).abs().max((new_b - b).abs()); + a = new_a; + b = new_b; + if moved < 1e-8 { + break; + } + } + + (a, b) +} + +/// The Rasch M-step: a one-parameter Newton solve with discrimination fixed at 1. +/// +/// # Arguments +/// +/// * `grid` - the ability grid. +/// * `counts` - expected `(administered, correct)` counts per grid point. +/// * `b0` - starting difficulty. +/// * `priors` - the priors. +/// +/// # Returns +/// +/// The updated difficulty. +fn newton_rasch(grid: &[f64], counts: &[(f64, f64)], b0: f64, priors: &Priors) -> f64 { + let mut b = b0; + for _ in 0..30 { + let mut g = 0.0; + let mut h = 0.0; + for (k, (n_k, r_k)) in counts.iter().enumerate() { + if *n_k <= 0.0 { + continue; + } + let p = logistic(grid[k] - b); + g += -(r_k - n_k * p); + h += -n_k * p * (1.0 - p); + } + if priors.enabled { + let t2 = priors.sd_b * priors.sd_b; + g += -(b - priors.mu_b) / t2; + h += -1.0 / t2; + } + if h.abs() < 1e-12 { + break; + } + let step = -g / h; + let new_b = (b + step).clamp(-B_MAX, B_MAX); + let moved = (new_b - b).abs(); + b = new_b; + if moved < 1e-8 { + break; + } + } + b +} + +/// Standard errors from the observed information at the solution. +/// +/// The information matrix is the negative Hessian; inverting it gives the +/// asymptotic covariance. When the determinant is not positive the parameters are +/// not locally identified, and `None` is returned rather than a fabricated number. +/// +/// # Arguments +/// +/// * `grid` - the ability grid. +/// * `counts` - expected counts. +/// * `a` - discrimination at the solution. +/// * `b` - difficulty at the solution. +/// * `c` - lower asymptote. +/// * `priors` - the priors, which contribute to the information. +/// +/// # Returns +/// +/// The standard errors of `a` and `b`. +fn standard_errors( + grid: &[f64], + counts: &[(f64, f64)], + a: f64, + b: f64, + c: f64, + priors: &Priors, +) -> (Option, Option) { + let mut sum_e = 0.0; + let mut sum_w = 0.0; + let mut sum_wu = 0.0; + let mut sum_wu2 = 0.0; + + for (k, (n_k, r_k)) in counts.iter().enumerate() { + if *n_k <= 0.0 { + continue; + } + let u = grid[k] - b; + let p_star = logistic(a * u); + let p = (c + (1.0 - c) * p_star).clamp(1e-12, 1.0 - 1e-12); + let w = n_k * p_star * (1.0 - p_star); + sum_e += r_k - n_k * p; + sum_w += w; + sum_wu += w * u; + sum_wu2 += w * u * u; + } + + let mut h11 = -sum_wu2; + // Not `mut`: see newton_2pl — the priors add no cross term. + let h12 = -sum_e + a * sum_wu; + let mut h22 = -a * a * sum_w; + if priors.enabled { + let la = a.ln(); + let s2 = priors.sd_log_a * priors.sd_log_a; + h11 += 1.0 / (a * a) + (la - priors.mu_log_a) / (s2 * a * a) - 1.0 / (s2 * a * a); + h22 += -1.0 / (priors.sd_b * priors.sd_b); + } + + let det = h11 * h22 - h12 * h12; + if det <= 0.0 || !det.is_finite() { + return (None, None); + } + // Inverse of the negative Hessian, diagonal entries. + let var_a = -h22 / det; + let var_b = -h11 / det; + ( + if var_a > 0.0 { + Some(var_a.sqrt()) + } else { + None + }, + if var_b > 0.0 { + Some(var_b.sqrt()) + } else { + None + }, + ) +} + +/// The printable name of a model. +fn model_name(model: IrtModel) -> &'static str { + match model { + IrtModel::Rasch => "the Rasch model", + IrtModel::TwoPl => "a 2PL model", + IrtModel::ThreePl => "a 3PL model", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::responses::Matrix; + + /// Builds a matrix from rows of 0/1 characters. + fn matrix_from(rows: &[&str]) -> Matrix { + let n_items = rows[0].len(); + let students: Vec = (0..rows.len()).map(|i| format!("s{i:02}")).collect(); + let items: Vec = (1..=n_items as u32).collect(); + let coded: Vec>> = rows + .iter() + .map(|r| { + r.chars() + .map(|ch| match ch { + '1' => Some(1), + '0' => Some(0), + _ => None, + }) + .collect() + }) + .collect(); + let credit: Vec>> = coded + .iter() + .map(|row| row.iter().map(|c| c.map(|v| v as f64)).collect()) + .collect(); + Matrix { + students, + items, + credit, + coded, + } + } + + /// A Guttman-like pattern: ability and difficulty both ordered. + fn ordered_matrix() -> Matrix { + matrix_from(&[ + "111111", "111110", "111100", "111000", "110000", "100000", "111111", "111110", + "111100", "111000", "110000", "100000", + ]) + } + + #[test] + fn logistic_is_stable_at_the_extremes() { + assert_eq!(logistic(-100.0), 0.0); + assert_eq!(logistic(100.0), 1.0); + assert!((logistic(0.0) - 0.5).abs() < 1e-12); + } + + #[test] + fn quadrature_weights_sum_to_one() { + let (theta, weight) = quadrature(); + assert_eq!(theta.len(), QUAD_POINTS); + let total: f64 = weight.iter().sum(); + assert!((total - 1.0).abs() < 1e-12, "got {total}"); + // Symmetric about zero. + assert!((weight[0] - weight[QUAD_POINTS - 1]).abs() < 1e-15); + } + + #[test] + fn difficulty_orders_with_the_p_value() { + let m = ordered_matrix(); + let f = fit(&m, &Options::default()); + assert_eq!(f.items.len(), 6); + // Item 1 was answered correctly by everyone, item 6 by almost nobody, so + // difficulty must increase across the form. + let b: Vec = f.items.iter().map(|i| i.b).collect(); + for w in b.windows(2) { + assert!(w[0] < w[1], "difficulty must be monotone, got {b:?}"); + } + } + + #[test] + fn abilities_track_total_score() { + let m = ordered_matrix(); + let f = fit(&m, &Options::default()); + let totals = m.correct_counts(); + let thetas: Vec = f.abilities.iter().map(|a| a.theta).collect(); + let r = crate::classical::correlation(&thetas, &totals).unwrap(); + // This is the property that matters: if abilities did not track total + // score on a short unidimensional test, the estimator would be wrong. + assert!(r > 0.95, "theta must track total score, got r = {r}"); + } + + #[test] + fn converges_on_well_behaved_data() { + let f = fit(&ordered_matrix(), &Options::default()); + assert!( + f.converged, + "should converge in {} iterations", + f.iterations + ); + assert!(f.iterations < 200); + assert!(f.log_likelihood.is_finite()); + } + + #[test] + fn a_unanimous_item_is_pinned_by_the_prior_and_says_so() { + // Every examinee correct on item 1. Unpenalized ML has no finite maximum + // here; the prior must keep it in range and the note must explain it. + let m = matrix_from(&["1101", "1110", "1100", "1010", "1111", "1000"]); + let f = fit(&m, &Options::default()); + let item1 = &f.items[0]; + assert!( + item1.b < -1.0, + "must be estimated as very easy, got {}", + item1.b + ); + assert!(item1.b > -B_MAX, "must not run off to the bound"); + assert!( + item1.notes.iter().any(|n| n.contains("prior")), + "must explain itself: {:?}", + item1.notes + ); + assert!(item1.bayesian); + } + + #[test] + fn small_samples_get_a_warning() { + let f = fit(&ordered_matrix(), &Options::default()); + assert!( + f.warnings.iter().any(|w| w.contains("examinees")), + "{:?}", + f.warnings + ); + } + + #[test] + fn disabling_priors_is_announced() { + let mut opts = Options::default(); + opts.priors.enabled = false; + let f = fit(&ordered_matrix(), &opts); + assert!(f.warnings.iter().any(|w| w.contains("priors are disabled"))); + } + + #[test] + fn rasch_fixes_discrimination_at_one() { + let opts = Options { + model: IrtModel::Rasch, + ..Default::default() + }; + let f = fit(&ordered_matrix(), &opts); + assert!(f.items.iter().all(|i| (i.a - 1.0).abs() < 1e-12)); + assert!(f.items.iter().all(|i| i.c.is_none())); + } + + #[test] + fn three_pl_reports_a_lower_asymptote_and_warns() { + let opts = Options { + model: IrtModel::ThreePl, + ..Default::default() + }; + let f = fit(&ordered_matrix(), &opts); + assert!(f.items.iter().all(|i| i.c.is_some())); + assert!( + f.items + .iter() + .all(|i| i.c.unwrap() >= 0.0 && i.c.unwrap() <= 0.4) + ); + assert!(f.warnings.iter().any(|w| w.contains("1000"))); + } + + #[test] + fn missing_responses_do_not_count_as_wrong() { + // Item 4 was administered to only the first three examinees. If missing + // were treated as incorrect it would look far harder than it is. + let with_missing = matrix_from(&["111.", "1110", "1101", "1..1", "110.", "100."]); + let f = fit(&with_missing, &Options::default()); + let item4 = &f.items[3]; + assert!( + item4.n < 6, + "only the administered responses count, got {}", + item4.n + ); + } + + #[test] + fn information_peaks_near_difficulty() { + let item = ItemFit { + number: 1, + a: 1.5, + b: 0.5, + c: None, + se_a: None, + se_b: None, + n: 30, + bayesian: true, + model: IrtModel::TwoPl, + notes: Vec::new(), + }; + // An item is most informative about students whose ability matches it. + assert!(item.information(0.5) > item.information(-1.5)); + assert!(item.information(0.5) > item.information(2.5)); + assert!((item.probability(0.5) - 0.5).abs() < 1e-12); + } + + #[test] + fn empty_input_does_not_panic() { + let m = Matrix { + students: Vec::new(), + items: Vec::new(), + credit: Vec::new(), + coded: Vec::new(), + }; + let f = fit(&m, &Options::default()); + assert!(f.items.is_empty()); + assert!(!f.warnings.is_empty()); + } +} diff --git a/src/analysis/students.rs b/src/analysis/students.rs new file mode 100644 index 0000000..dd153e3 --- /dev/null +++ b/src/analysis/students.rs @@ -0,0 +1,1146 @@ +// SPDX-License-Identifier: Prosperity-3.0.0 +// Copyright Scientific Computing Studio +// Source: https://git.scient.ing/education/coursebank + +//! Turning responses into something you can say to a student. +//! +//! Item analysis tells you about items. This module tells you about people: which +//! objectives a student has actually met, which ones they are close on, and what +//! specifically to do next. +//! +//! # On declaring mastery from three questions +//! +//! The honest answer is that you often cannot. Two items on an objective give a +//! proportion with an enormous confidence interval — two out of two correct is +//! consistent with a true rate anywhere from about 0.55 upward. So this module +//! does three things instead of pretending otherwise. +//! +//! It refuses to classify at all below `min_items_for_mastery`, reporting "not +//! enough evidence", which is a finding about your blueprint rather than about the +//! student. It reports the Wilson score interval alongside every rate, because +//! Wilson behaves sensibly at the boundaries where the normal approximation +//! produces intervals extending past 1.0. And it separates the *classification* +//! (which uses the observed rate, so it is usable) from the *confidence* (which +//! uses the interval, so it is honest). A student can be "meeting" an objective +//! provisionally, and the report says so. +//! +//! # Comparison to the cohort +//! +//! Per-level performance is reported against the class rather than in absolute +//! terms, because "you got 60% of the Analyze items" means nothing to a student +//! without knowing that the class average was 55%. The comparison is descriptive, +//! not a curve. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::course::{CourseFile, Policy}; +use crate::responses::{Response, ResponseSet}; +use crate::rng::Rng; +use crate::taxonomy::Level; + +/// How well a student has met one objective. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mastery { + /// Met the threshold. + Meeting, + /// Partway there. + Developing, + /// Not yet. + NotYet, + /// Too few items on this objective to say anything. This is a gap in the + /// assessment, not a judgment about the student. + NotEnoughEvidence, +} + +impl Mastery { + /// A label for reports. + pub fn label(self) -> &'static str { + match self { + Mastery::Meeting => "meeting", + Mastery::Developing => "developing", + Mastery::NotYet => "not yet", + Mastery::NotEnoughEvidence => "not enough evidence", + } + } + + /// A short symbol for compact tables. + pub fn symbol(self) -> &'static str { + match self { + Mastery::Meeting => "✓", + Mastery::Developing => "~", + Mastery::NotYet => "✗", + Mastery::NotEnoughEvidence => "?", + } + } +} + +/// One student's standing on one objective. +#[derive(Debug, Clone)] +pub struct ObjectiveMastery { + /// The objective id. + pub objective: String, + /// The objective text, for reports. + pub text: String, + /// How many items on this objective the student saw. + pub n_items: usize, + /// How many they got right, counting partial credit. + pub credit: f64, + /// Observed rate, `credit / n_items`. + pub rate: f64, + /// Lower end of the Wilson interval. + pub wilson_lower: f64, + /// Upper end of the Wilson interval. + pub wilson_upper: f64, + /// The class's rate on the same objective. + pub cohort_rate: f64, + /// The classification. + pub status: Mastery, + /// Whether the interval, not just the point estimate, clears the threshold. + pub confident: bool, + /// Which levels the objective was assessed at, since meeting an objective at + /// Remember is a different claim from meeting it at Analyze. + pub levels: Vec, +} + +/// One student's performance at one cognitive level. +#[derive(Debug, Clone)] +pub struct LevelProfile { + /// The level. + pub level: Level, + /// How many items at this level. + pub n_items: usize, + /// The student's rate. + pub rate: f64, + /// The class's rate. + pub cohort_rate: f64, + /// Difference from the class, in class standard deviations. `None` when the + /// class had no spread at this level. + pub z: Option, +} + +impl LevelProfile { + /// A plain-language comparison to the class. + pub fn comparison(&self) -> &'static str { + match self.z { + Some(z) if z >= 1.0 => "well above the class", + Some(z) if z >= 0.4 => "above the class", + Some(z) if z > -0.4 => "about the same as the class", + Some(z) if z > -1.0 => "below the class", + Some(_) => "well below the class", + None => "the class did not vary here", + } + } +} + +/// An item the student got wrong, with what to do about it. +#[derive(Debug, Clone)] +pub struct MissedItem { + /// The question number. + pub number: u32, + /// The item's global id. + pub item_ref: Option, + /// What the student chose. + pub selected: Vec, + /// Credit earned, since a partially credited response is not a clean miss. + pub credit: f64, + /// The level. + pub level: Option, + /// The objectives involved. + pub learning_objectives: Vec, + /// The misconception the chosen distractor was written to detect. + pub misconception: Option, + /// Feedback written for a student who chose that option. + pub feedback: Option, + /// Where to go back to: lecture titles and slide numbers. + pub study: Vec, +} + +/// Everything needed to write one student's report. +#[derive(Debug, Clone)] +pub struct StudentSummary { + /// The grouping key. + pub student_key: String, + /// The name, when not pseudonymized. + pub name: Option, + /// The student id, when not pseudonymized. + pub sid: Option, + /// Points earned on scored items. + pub points: f64, + /// Points available on scored items. + pub points_possible: f64, + /// Percentage on scored items. + pub percent: f64, + /// Bonus points earned. + pub bonus_points: f64, + /// Items answered correctly. + pub correct: usize, + /// Items administered. + pub n_items: usize, + /// Where the score falls relative to the class, as a coarse band. + pub band: String, + /// IRT ability, when an IRT fit was supplied. + pub theta: Option, + /// Standard error of the ability estimate. + pub theta_se: Option, + /// Per-objective standing, in the course's objective order. + pub objectives: Vec, + /// Per-level standing. + pub levels: Vec, + /// Objectives the student is clearly meeting. + pub strengths: Vec, + /// Objectives to work on, worst first. + pub focus: Vec, + /// Missed items with targeted guidance. + pub missed: Vec, +} + +impl StudentSummary { + /// The display name, falling back to the key. + pub fn display_name(&self) -> String { + self.name + .clone() + .unwrap_or_else(|| self.student_key.clone()) + } +} + +/// Class-level context. +#[derive(Debug, Clone)] +pub struct Cohort { + /// Per-student summaries, sorted by key. + pub students: Vec, + /// Class rate per objective. + pub objective_rates: BTreeMap, + /// Class rate per level. + pub level_rates: BTreeMap, + /// Mean percentage. + pub mean_percent: f64, + /// Standard deviation of percentage. + pub sd_percent: f64, + /// Objectives the class as a whole did not meet, worst first. This is the + /// list that should change what you reteach. + pub class_gaps: Vec<(String, f64)>, + /// Optional grouping of students by response profile. + pub archetypes: Vec, +} + +/// A cluster of students with a similar profile across levels. +#[derive(Debug, Clone)] +pub struct Archetype { + /// A label describing the pattern. + pub label: String, + /// The student keys in this cluster. + pub members: Vec, + /// Mean rate at each level for this cluster. + pub level_means: BTreeMap, +} + +/// The Wilson score interval for a binomial proportion. +/// +/// Preferred over the normal approximation because it stays inside `[0, 1]` and +/// behaves at the boundaries, which is exactly where classroom data lives: a +/// student who got three out of three needs an interval, and the textbook formula +/// gives width zero there. +/// +/// # Arguments +/// +/// * `successes` - the number of successes, which may be fractional when partial +/// credit is involved. +/// * `n` - the number of trials. +/// * `z` - the standard normal quantile; 1.96 for a two-sided 95% interval. +/// +/// # Returns +/// +/// The lower and upper bounds, or `(0.0, 1.0)` when there are no trials. +pub fn wilson(successes: f64, n: usize, z: f64) -> (f64, f64) { + if n == 0 { + return (0.0, 1.0); + } + let n = n as f64; + let p = (successes / n).clamp(0.0, 1.0); + let z2 = z * z; + let denominator = 1.0 + z2 / n; + let center = p + z2 / (2.0 * n); + let spread = z * ((p * (1.0 - p) / n) + z2 / (4.0 * n * n)).sqrt(); + ( + ((center - spread) / denominator).clamp(0.0, 1.0), + ((center + spread) / denominator).clamp(0.0, 1.0), + ) +} + +/// Builds per-student summaries for one administration. +/// +/// # Arguments +/// +/// * `set` - the responses, already enriched with item metadata. +/// * `course` - the course, for objective text, order, and policy. +/// * `catalog` - the loaded course, for misconception feedback on missed items. +/// * `fit` - an optional IRT fit, whose abilities are attached when present. +/// +/// # Returns +/// +/// The cohort. +pub fn summarize( + set: &ResponseSet, + course: &CourseFile, + catalog: Option<&crate::catalog::Catalog>, + fit: Option<&crate::irt::Fit>, +) -> Cohort { + let policy = &course.policy; + let students = set.students(); + + // Class rates first: every student's report is relative to these. + let objective_rates = rates_by_objective(&set.rows.iter().collect::>()); + let level_rates = rates_by_level(&set.rows.iter().collect::>()); + + // Per-level spread across students, for the z comparisons. + let mut level_values: BTreeMap> = BTreeMap::new(); + for key in &students { + let rows = set.for_student(key); + for (level, rate) in rates_by_level(&rows) { + level_values.entry(level).or_default().push(rate); + } + } + let level_sd: BTreeMap = level_values + .iter() + .map(|(level, values)| (*level, sd(values))) + .collect(); + + let percents: Vec = students + .iter() + .map(|key| { + let earned = set.scored_total(key); + let possible = set.points_available(); + if possible > 0.0 { + 100.0 * earned / possible + } else { + 0.0 + } + }) + .collect(); + let mean_percent = mean(&percents); + let sd_percent = sd(&percents); + + let ability = fit.map(|f| f.ability_map()).unwrap_or_default(); + let ability_se: BTreeMap = fit + .map(|f| { + f.abilities + .iter() + .map(|a| (a.student_key.clone(), a.se)) + .collect() + }) + .unwrap_or_default(); + + let order = course.objectives_in_order(); + let mut summaries = Vec::with_capacity(students.len()); + + for (index, key) in students.iter().enumerate() { + let rows = set.for_student(key); + let points = set.scored_total(key); + let possible = set.points_available(); + let percent = percents[index]; + + let correct = rows + .iter() + .filter(|r| r.counts() && r.correct == Some(true)) + .count(); + let n_items = rows.iter().filter(|r| r.counts()).count(); + + // Objectives, in the course's declared order so reports read the way the + // course is taught rather than alphabetically. + let per_objective = rates_by_objective(&rows); + let counts = counts_by_objective(&rows); + let mut objectives = Vec::new(); + let mut seen: BTreeSet<&String> = BTreeSet::new(); + for id in order.iter().chain(per_objective.keys()) { + if !seen.insert(id) { + continue; + } + let Some((n, credit)) = counts.get(id).copied() else { + continue; + }; + objectives.push(objective_mastery( + id, + course, + n, + credit, + objective_rates.get(id).copied().unwrap_or(0.0), + &rows, + policy, + )); + } + + // Levels. + let student_levels = rates_by_level(&rows); + let level_counts = counts_by_level(&rows); + let levels: Vec = Level::ALL + .iter() + .filter_map(|level| { + let (n, _) = level_counts.get(level).copied()?; + if n == 0 { + return None; + } + let rate = student_levels.get(level).copied().unwrap_or(0.0); + let cohort_rate = level_rates.get(level).copied().unwrap_or(0.0); + let spread = level_sd.get(level).copied().unwrap_or(0.0); + Some(LevelProfile { + level: *level, + n_items: n, + rate, + cohort_rate, + z: if spread > 1e-9 { + Some((rate - cohort_rate) / spread) + } else { + None + }, + }) + }) + .collect(); + + // Strengths and focus areas. Strengths need confidence, focus areas do + // not: telling a student to review something they may already know costs + // them an hour, while telling them they have mastered something they have + // not costs them the next exam. + let strengths: Vec = objectives + .iter() + .filter(|o| o.status == Mastery::Meeting && o.confident) + .map(|o| o.objective.clone()) + .collect(); + let mut focus_pairs: Vec<(&ObjectiveMastery, f64)> = objectives + .iter() + .filter(|o| matches!(o.status, Mastery::NotYet | Mastery::Developing)) + .map(|o| (o, o.rate)) + .collect(); + focus_pairs.sort_by(|a, b| { + a.1.partial_cmp(&b.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.objective.cmp(&b.0.objective)) + }); + let focus: Vec = focus_pairs + .iter() + .map(|(o, _)| o.objective.clone()) + .collect(); + + let missed = missed_items(&rows, catalog, course); + + summaries.push(StudentSummary { + student_key: key.clone(), + name: rows.first().and_then(|r| r.name.clone()), + sid: rows.first().and_then(|r| r.sid.clone()), + points, + points_possible: possible, + percent, + bonus_points: set.bonus_total(key), + correct, + n_items, + band: band_for(percent, &percents), + theta: ability.get(key).copied(), + theta_se: ability_se.get(key).copied(), + objectives, + levels, + strengths, + focus, + missed, + }); + } + + // Class gaps: objectives where the whole class fell short. These are the ones + // to reteach rather than to send individual students away to review. + let mut class_gaps: Vec<(String, f64)> = objective_rates + .iter() + .filter(|(_, rate)| **rate < policy.mastery_threshold) + .map(|(id, rate)| (id.clone(), *rate)) + .collect(); + class_gaps.sort_by(|a, b| { + a.1.partial_cmp(&b.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.cmp(&b.0)) + }); + + let archetypes = cluster(&summaries, 3); + + Cohort { + students: summaries, + objective_rates, + level_rates, + mean_percent, + sd_percent, + class_gaps, + archetypes, + } +} + +/// Builds one objective's mastery record. +/// +/// # Arguments +/// +/// * `id` - the objective id. +/// * `course` - the course, for text and policy. +/// * `n` - items on this objective. +/// * `credit` - total credit earned. +/// * `cohort_rate` - the class rate. +/// * `rows` - the student's responses, for the level list. +/// * `policy` - the course policy. +/// +/// # Returns +/// +/// The record. +fn objective_mastery( + id: &str, + course: &CourseFile, + n: usize, + credit: f64, + cohort_rate: f64, + rows: &[&Response], + policy: &Policy, +) -> ObjectiveMastery { + let rate = if n > 0 { credit / n as f64 } else { 0.0 }; + let (lower, upper) = wilson(credit, n, 1.96); + + let status = if n < policy.min_items_for_mastery.max(1) { + Mastery::NotEnoughEvidence + } else if rate >= policy.mastery_threshold { + Mastery::Meeting + } else if rate >= policy.mastery_threshold * 0.6 { + Mastery::Developing + } else { + Mastery::NotYet + }; + + let levels: Vec = rows + .iter() + .filter(|r| r.learning_objectives.iter().any(|o| o == id)) + .filter_map(|r| r.level) + .collect::>() + .into_iter() + .collect(); + + ObjectiveMastery { + objective: id.to_string(), + text: course.objective_text(id), + n_items: n, + credit, + rate, + wilson_lower: lower, + wilson_upper: upper, + cohort_rate, + status, + confident: lower >= policy.mastery_threshold, + levels, + } +} + +/// Collects missed items with targeted guidance. +/// +/// The guidance comes from the item's own authoring: the `misconception` recorded +/// on the distractor the student actually chose, and the lecture and slides the +/// item was written from. This is why authoring distractors deliberately pays off +/// twice — once when writing the item, and again in every report afterward. +/// +/// # Arguments +/// +/// * `rows` - the student's responses. +/// * `catalog` - the loaded course. +/// * `course` - the course, for lecture titles. +/// +/// # Returns +/// +/// The missed items, in question order. +fn missed_items( + rows: &[&Response], + catalog: Option<&crate::catalog::Catalog>, + course: &CourseFile, +) -> Vec { + let mut out = Vec::new(); + for r in rows { + if !r.counts() || r.credit >= 0.999 { + continue; + } + let mut misconception = None; + let mut feedback = None; + let mut study = Vec::new(); + + if let (Some(cat), Some(uid)) = (catalog, r.item_ref.as_deref()) { + if let Some(entry) = cat.get(uid) { + // Feedback for the specific option chosen, which is the whole + // point of recording per-distractor misconceptions. + if let Some(letter) = r.selected.first() { + if let Some(choice) = entry.item.option(letter) { + misconception = choice.misconception.clone(); + feedback = choice.student_text().map(|s| s.to_string()); + } + } + for source in &entry.item.sources { + let title = course + .lectures + .get(&source.lecture) + .map(|l| l.title.clone()) + .unwrap_or_else(|| source.lecture.clone()); + if source.slides.is_empty() { + study.push(title); + } else { + let slides: Vec = + source.slides.iter().map(|s| s.to_string()).collect(); + study.push(format!("{title}, slides {}", slides.join(", "))); + } + for reading in &source.readings { + study.push(reading.clone()); + } + } + } + } + + out.push(MissedItem { + number: r.item_number, + item_ref: r.item_ref.clone(), + selected: r.selected.clone(), + credit: r.credit, + level: r.level, + learning_objectives: r.learning_objectives.clone(), + misconception, + feedback, + study, + }); + } + out +} + +/// Credit rate per objective over a set of responses. +/// +/// # Arguments +/// +/// * `rows` - the responses. +/// +/// # Returns +/// +/// The rate for each objective mentioned. +pub fn rates_by_objective(rows: &[&Response]) -> BTreeMap { + counts_by_objective(rows) + .into_iter() + .map(|(id, (n, credit))| { + let rate = if n > 0 { credit / n as f64 } else { 0.0 }; + (id, rate) + }) + .collect() +} + +/// Item counts and credit per objective. +/// +/// An item tagged with two objectives counts toward both. That double counting is +/// intentional: the question "how is this student doing on kinetics" should use +/// every item that measured kinetics. +/// +/// # Arguments +/// +/// * `rows` - the responses. +/// +/// # Returns +/// +/// `(item count, total credit)` per objective. +pub fn counts_by_objective(rows: &[&Response]) -> BTreeMap { + let mut out: BTreeMap = BTreeMap::new(); + for r in rows { + if !r.counts() { + continue; + } + for objective in &r.learning_objectives { + let e = out.entry(objective.clone()).or_insert((0, 0.0)); + e.0 += 1; + e.1 += r.credit.clamp(0.0, 1.0); + } + } + out +} + +/// Credit rate per level. +/// +/// # Arguments +/// +/// * `rows` - the responses. +/// +/// # Returns +/// +/// The rate for each level present. +pub fn rates_by_level(rows: &[&Response]) -> BTreeMap { + counts_by_level(rows) + .into_iter() + .map(|(level, (n, credit))| { + let rate = if n > 0 { credit / n as f64 } else { 0.0 }; + (level, rate) + }) + .collect() +} + +/// Item counts and credit per level. +/// +/// # Arguments +/// +/// * `rows` - the responses. +/// +/// # Returns +/// +/// `(item count, total credit)` per level. +pub fn counts_by_level(rows: &[&Response]) -> BTreeMap { + let mut out: BTreeMap = BTreeMap::new(); + for r in rows { + if !r.counts() { + continue; + } + if let Some(level) = r.level { + let e = out.entry(level).or_insert((0, 0.0)); + e.0 += 1; + e.1 += r.credit.clamp(0.0, 1.0); + } + } + out +} + +/// A coarse band for a score within a class. +/// +/// Quartile bands rather than an exact percentile, because a percentile computed +/// from twenty-four students implies a precision it does not have, and because +/// telling a student they are "37th percentile" invites comparison in a way that +/// "middle half of the class" does not. +/// +/// # Arguments +/// +/// * `percent` - the student's percentage. +/// * `all` - every student's percentage. +/// +/// # Returns +/// +/// The band label. +fn band_for(percent: f64, all: &[f64]) -> String { + if all.len() < 4 { + return "the class is too small to place this meaningfully".to_string(); + } + let mut sorted = all.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let below = sorted.iter().filter(|x| **x < percent).count() as f64; + let fraction = below / all.len() as f64; + if fraction >= 0.75 { + "top quarter of the class".to_string() + } else if fraction >= 0.5 { + "upper middle of the class".to_string() + } else if fraction >= 0.25 { + "lower middle of the class".to_string() + } else { + "bottom quarter of the class".to_string() + } +} + +/// Groups students by their profile across levels. +/// +/// This is descriptive, not diagnostic. It answers "are there recognizable +/// patterns in how this class is struggling" — for instance a group that handles +/// recall fine and falls apart on application, which calls for different +/// instruction than a group that is uniformly behind. +/// +/// k-means with a seeded, deterministic initialization, so the same data always +/// produces the same groups. +/// +/// # Arguments +/// +/// * `students` - the summaries. +/// * `k` - how many clusters to look for. +/// +/// # Returns +/// +/// The clusters, largest first. Empty when there are too few students to bother. +pub fn cluster(students: &[StudentSummary], k: usize) -> Vec { + // Below about three students per cluster the groups are noise. + if students.len() < k * 3 || k == 0 { + return Vec::new(); + } + + // Feature vector: rate at each level that anyone was assessed on. + let levels: Vec = students + .iter() + .flat_map(|s| s.levels.iter().map(|l| l.level)) + .collect::>() + .into_iter() + .collect(); + if levels.len() < 2 { + return Vec::new(); + } + + let points: Vec> = students + .iter() + .map(|s| { + levels + .iter() + .map(|level| { + s.levels + .iter() + .find(|l| l.level == *level) + .map(|l| l.rate) + .unwrap_or(0.0) + }) + .collect() + }) + .collect(); + + // Standardize each dimension so a level everyone did well on does not + // dominate the distance. + let mut standardized = points.clone(); + for d in 0..levels.len() { + let column: Vec = points.iter().map(|p| p[d]).collect(); + let m = mean(&column); + let s = sd(&column); + for (i, point) in standardized.iter_mut().enumerate() { + point[d] = if s > 1e-9 { + (points[i][d] - m) / s + } else { + 0.0 + }; + } + } + + // Seeded k-means++ initialization. + let mut rng = Rng::from_label("coursebank/archetypes"); + let mut centers: Vec> = + vec![standardized[rng.below(standardized.len() as u64) as usize].clone()]; + while centers.len() < k { + let distances: Vec = standardized + .iter() + .map(|p| { + centers + .iter() + .map(|c| squared_distance(p, c)) + .fold(f64::INFINITY, f64::min) + }) + .collect(); + let total: f64 = distances.iter().sum(); + if total <= 0.0 { + break; + } + let mut target = rng.unit() * total; + let mut chosen = standardized.len() - 1; + for (i, d) in distances.iter().enumerate() { + target -= d; + if target <= 0.0 { + chosen = i; + break; + } + } + centers.push(standardized[chosen].clone()); + } + + let mut assignment = vec![0usize; standardized.len()]; + for _ in 0..50 { + let mut changed = false; + for (i, p) in standardized.iter().enumerate() { + let mut best = (0usize, f64::INFINITY); + for (c, center) in centers.iter().enumerate() { + let d = squared_distance(p, center); + if d < best.1 { + best = (c, d); + } + } + if assignment[i] != best.0 { + assignment[i] = best.0; + changed = true; + } + } + for (c, center) in centers.iter_mut().enumerate() { + let members: Vec<&Vec> = standardized + .iter() + .enumerate() + .filter(|(i, _)| assignment[*i] == c) + .map(|(_, p)| p) + .collect(); + if members.is_empty() { + continue; + } + for d in 0..levels.len() { + center[d] = members.iter().map(|p| p[d]).sum::() / members.len() as f64; + } + } + if !changed { + break; + } + } + + let mut out = Vec::new(); + for c in 0..centers.len() { + let members: Vec = students + .iter() + .enumerate() + .filter(|(i, _)| assignment[*i] == c) + .map(|(_, s)| s.student_key.clone()) + .collect(); + if members.is_empty() { + continue; + } + let mut level_means = BTreeMap::new(); + for (d, level) in levels.iter().enumerate() { + let values: Vec = students + .iter() + .enumerate() + .filter(|(i, _)| assignment[*i] == c) + .map(|(i, _)| points[i][d]) + .collect(); + level_means.insert(*level, mean(&values)); + } + out.push(Archetype { + label: label_for(&level_means), + members, + level_means, + }); + } + out.sort_by_key(|b| std::cmp::Reverse(b.members.len())); + out +} + +/// Names a cluster from its level profile. +/// +/// # Arguments +/// +/// * `means` - mean rate at each level. +/// +/// # Returns +/// +/// A descriptive label. +fn label_for(means: &BTreeMap) -> String { + let values: Vec = means.values().copied().collect(); + let overall = mean(&values); + + // Is the profile flat, or does it fall off with cognitive demand? + let low: Vec = means + .iter() + .filter(|(l, _)| l.code() <= 2) + .map(|(_, v)| *v) + .collect(); + let high: Vec = means + .iter() + .filter(|(l, _)| l.code() >= 3) + .map(|(_, v)| *v) + .collect(); + + if !low.is_empty() && !high.is_empty() { + let drop = mean(&low) - mean(&high); + if drop > 0.25 { + return "knows the material, struggles to apply it".to_string(); + } + if drop < -0.15 { + return "reasons well, gaps in recall".to_string(); + } + } + + if overall >= 0.85 { + "consistently strong".to_string() + } else if overall >= 0.65 { + "solid with scattered gaps".to_string() + } else { + "behind across the board".to_string() + } +} + +/// Squared Euclidean distance. +fn squared_distance(a: &[f64], b: &[f64]) -> f64 { + a.iter().zip(b.iter()).map(|(x, y)| (x - y) * (x - y)).sum() +} + +/// The arithmetic mean, zero for an empty slice. +fn mean(v: &[f64]) -> f64 { + if v.is_empty() { + 0.0 + } else { + v.iter().sum::() / v.len() as f64 + } +} + +/// The population standard deviation. +fn sd(v: &[f64]) -> f64 { + if v.len() < 2 { + return 0.0; + } + let m = mean(v); + (v.iter().map(|x| (x - m) * (x - m)).sum::() / v.len() as f64).sqrt() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wilson_stays_inside_zero_and_one() { + // Three out of three: the naive interval has zero width, Wilson does not. + let (lo, hi) = wilson(3.0, 3, 1.96); + assert!(lo > 0.0 && lo < 1.0, "lower bound {lo}"); + assert_eq!(hi, 1.0); + assert!(lo < 0.5, "three items cannot establish a high rate: {lo}"); + + // Zero out of four. + let (lo, hi) = wilson(0.0, 4, 1.96); + assert_eq!(lo, 0.0); + assert!(hi > 0.0 && hi < 1.0); + + // No data at all. + assert_eq!(wilson(0.0, 0, 1.96), (0.0, 1.0)); + } + + #[test] + fn wilson_narrows_as_n_grows() { + let (lo_small, hi_small) = wilson(8.0, 10, 1.96); + let (lo_big, hi_big) = wilson(80.0, 100, 1.96); + assert!( + (hi_big - lo_big) < (hi_small - lo_small), + "more data must give a tighter interval" + ); + } + + #[test] + fn two_items_never_claim_confident_mastery() { + // The classification may say "meeting", but confidence must not, because + // two items cannot establish a rate of 0.75. + let (lower, _) = wilson(2.0, 2, 1.96); + assert!(lower < 0.75, "got {lower}"); + } + + #[test] + fn bands_describe_position_coarsely() { + let all = vec![50.0, 60.0, 70.0, 80.0, 90.0, 95.0, 40.0, 30.0]; + assert!(band_for(95.0, &all).contains("top")); + assert!(band_for(30.0, &all).contains("bottom")); + // Too few students to place anyone. + assert!(band_for(50.0, &[50.0, 60.0]).contains("too small")); + } + + #[test] + fn objective_counts_credit_every_tagged_item() { + let rows = [ + make("s1", 1, 1.0, &["lo-a", "lo-b"], Some(Level::Remember)), + make("s1", 2, 0.0, &["lo-a"], Some(Level::Apply)), + ]; + let refs: Vec<&Response> = rows.iter().collect(); + let counts = counts_by_objective(&refs); + // lo-a saw both items; lo-b only the first. + assert_eq!(counts["lo-a"], (2, 1.0)); + assert_eq!(counts["lo-b"], (1, 1.0)); + let rates = rates_by_objective(&refs); + assert_eq!(rates["lo-a"], 0.5); + assert_eq!(rates["lo-b"], 1.0); + } + + #[test] + fn level_rates_ignore_untagged_items() { + let rows = [ + make("s1", 1, 1.0, &[], Some(Level::Remember)), + make("s1", 2, 0.0, &[], None), + ]; + let refs: Vec<&Response> = rows.iter().collect(); + let counts = counts_by_level(&refs); + assert_eq!(counts.len(), 1); + assert_eq!(counts[&Level::Remember], (1, 1.0)); + } + + #[test] + fn mastery_labels_are_stable() { + assert_eq!(Mastery::Meeting.label(), "meeting"); + assert_eq!(Mastery::NotEnoughEvidence.label(), "not enough evidence"); + } + + #[test] + fn clustering_needs_enough_students() { + assert!(cluster(&[], 3).is_empty()); + let few: Vec = (0..4).map(|i| summary(&format!("s{i}"))).collect(); + assert!(cluster(&few, 3).is_empty(), "four students, three clusters"); + } + + #[test] + fn clustering_is_deterministic_and_separates_profiles() { + // Half the class is strong on recall and weak on application; half is + // uniformly strong. Those are different problems. + let mut students = Vec::new(); + for i in 0..12 { + let mut s = summary(&format!("s{i:02}")); + let (recall, apply) = if i < 6 { (0.95, 0.35) } else { (0.9, 0.85) }; + s.levels = vec![ + profile(Level::Remember, recall), + profile(Level::Apply, apply), + ]; + students.push(s); + } + let first = cluster(&students, 2); + let second = cluster(&students, 2); + assert_eq!(first.len(), 2); + assert_eq!( + first.iter().map(|a| a.members.clone()).collect::>(), + second.iter().map(|a| a.members.clone()).collect::>(), + "clustering must be reproducible" + ); + // The two groups must not be mixed together. + let sizes: Vec = first.iter().map(|a| a.members.len()).collect(); + assert_eq!(sizes, vec![6, 6], "got {sizes:?}"); + assert!(first.iter().any(|a| a.label.contains("struggles to apply"))); + } + + fn make( + student: &str, + number: u32, + credit: f64, + objectives: &[&str], + level: Option, + ) -> Response { + Response { + administration_id: "C/T/a".into(), + course: "C".into(), + term: "T".into(), + assessment_id: "a".into(), + date: None, + form: None, + student_key: student.into(), + sid: None, + 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: 1.0, + score: credit, + response_time_seconds: None, + level, + learning_objectives: objectives.iter().map(|s| s.to_string()).collect(), + topics: vec![], + bonus: false, + dropped: false, + } + } + + fn summary(key: &str) -> StudentSummary { + StudentSummary { + student_key: key.to_string(), + name: None, + sid: None, + points: 0.0, + points_possible: 0.0, + percent: 0.0, + bonus_points: 0.0, + correct: 0, + n_items: 0, + band: String::new(), + theta: None, + theta_se: None, + objectives: Vec::new(), + levels: Vec::new(), + strengths: Vec::new(), + focus: Vec::new(), + missed: Vec::new(), + } + } + + fn profile(level: Level, rate: f64) -> LevelProfile { + LevelProfile { + level, + n_items: 4, + rate, + cohort_rate: rate, + z: None, + } + } +} diff --git a/src/authoring.rs b/src/authoring.rs new file mode 100644 index 0000000..5ba522e --- /dev/null +++ b/src/authoring.rs @@ -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; diff --git a/src/authoring/jsonschema.rs b/src/authoring/jsonschema.rs new file mode 100644 index 0000000..329ce36 --- /dev/null +++ b/src/authoring/jsonschema.rs @@ -0,0 +1,1025 @@ +// SPDX-License-Identifier: Prosperity-3.0.0 +// Copyright Scientific Computing Studio +// Source: https://git.scient.ing/education/coursebank + +//! Emitting JSON Schema for the YAML formats. +//! +//! The point of this module is autocomplete. Every editor with a YAML language +//! server reads a `# yaml-language-server: $schema=...` modeline, and once it does, +//! writing an item becomes a matter of tabbing through valid `cognitive_process` +//! values instead of looking them up. That is a much better authoring experience +//! than running a validator afterward and reading a list of typos. +//! +//! The schemas are written by hand rather than derived from the Rust types. That is +//! a real cost — two definitions to keep in step — bought for two reasons: the +//! schema can carry prose descriptions aimed at whoever is writing the item, which +//! is what shows up in editor tooltips, and it can encode `enum` value lists that a +//! generic derivation would emit as bare strings. The crate's own validation +//! remains authoritative; the schema is for the editor. + +use serde_json::{Value, json}; + +use crate::course::SCHEMA_VERSION; +use crate::error::Result; +use crate::taxonomy::{CognitiveProcess, ErrorType, Flag, Level}; + +/// The base URL schemas refer to each other by. +const BASE: &str = "https://coursebank.dev/schema"; + +/// The three schema kinds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// `course.yaml`. + Course, + /// `banks/*.yaml`. + Bank, + /// `assessments/*.yaml`. + Assessment, +} + +impl Kind { + /// All three kinds. + pub const ALL: [Kind; 3] = [Kind::Course, Kind::Bank, Kind::Assessment]; + + /// The file name a schema is written to. + pub fn filename(self) -> &'static str { + match self { + Kind::Course => "course.schema.json", + Kind::Bank => "bank.schema.json", + Kind::Assessment => "assessment.schema.json", + } + } + + /// The modeline that points an editor at this schema. + /// + /// # Arguments + /// + /// * `relative_path` - the path from the YAML file to the schema directory. + /// + /// # Returns + /// + /// A comment line to place at the top of the YAML file. + pub fn modeline(self, relative_path: &str) -> String { + format!( + "# yaml-language-server: $schema={}/{}", + relative_path.trim_end_matches('/'), + self.filename() + ) + } +} + +/// Builds a schema. +/// +/// # Arguments +/// +/// * `kind` - which schema to build. +/// +/// # Returns +/// +/// The schema as JSON. +pub fn schema(kind: Kind) -> Value { + match kind { + Kind::Course => course_schema(), + Kind::Bank => bank_schema(), + Kind::Assessment => assessment_schema(), + } +} + +/// Writes all three schemas to a directory. +/// +/// # Arguments +/// +/// * `dir` - the destination directory. +/// +/// # Returns +/// +/// The paths written. +/// +/// # Errors +/// +/// Returns [`crate::error::Error::Io`] on a write failure. +pub fn write_all(dir: &std::path::Path) -> Result> { + std::fs::create_dir_all(dir).map_err(|e| crate::error::Error::io(dir, e))?; + let mut written = Vec::new(); + for kind in Kind::ALL { + let path = dir.join(kind.filename()); + crate::yaml::write_json(&path, &schema(kind))?; + written.push(path); + } + Ok(written) +} + +/// The string values of an enum, for a schema `enum` list. +fn strings>(values: &[T]) -> Value { + Value::Array( + values + .iter() + .map(|v| Value::String(v.as_ref().to_string())) + .collect(), + ) +} + +/// A schema fragment for a required non-empty string. +fn text(description: &str) -> Value { + json!({ "type": "string", "minLength": 1, "description": description }) +} + +/// A schema fragment for an array of strings. +fn string_array(description: &str) -> Value { + json!({ + "type": "array", + "items": { "type": "string" }, + "description": description + }) +} + +/// A schema fragment for a proportion in `[0, 1]`. +fn proportion(description: &str) -> Value { + json!({ + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": description + }) +} + +/// A schema fragment for a `YYYY-MM-DD` date. +fn date(description: &str) -> Value { + json!({ + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "description": description + }) +} + +/// The level enum, with the taxonomy spelled out in the description so it appears +/// in editor tooltips. +fn level() -> Value { + let descriptions: Vec = Level::ALL + .iter() + .map(|l| format!("{} = {} ({})", l.code(), l.name(), l.blurb())) + .collect(); + json!({ + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": format!("Cognitive level. {}", descriptions.join("; ")) + }) +} + +/// The cognitive process enum, grouped by level in the description. +fn cognitive_process() -> Value { + let all: Vec<&str> = CognitiveProcess::ALL.iter().map(|p| p.as_str()).collect(); + let by_level: Vec = Level::ALL + .iter() + .map(|l| { + let names: Vec<&str> = l.processes().iter().map(|p| p.as_str()).collect(); + format!("level {}: {}", l.code(), names.join(", ")) + }) + .collect(); + json!({ + "type": "string", + "enum": strings(&all), + "description": format!( + "The specific cognitive operation. Must belong to the item's level — {}.", + by_level.join("; ") + ) + }) +} + +/// The distractor error-type enum, with each gloss in the description. +fn error_type() -> Value { + let all: Vec<&str> = ErrorType::ALL.iter().map(|e| e.as_str()).collect(); + let glosses: Vec = ErrorType::ALL + .iter() + .map(|e| format!("{} — {}", e.as_str(), e.gloss())) + .collect(); + json!({ + "type": "string", + "enum": strings(&all), + "description": format!( + "What kind of mistake this distractor is designed to catch. {}", + glosses.join("; ") + ) + }) +} + +/// The schema for the `course` identity block. +fn course_identity_schema() -> Value { + json!({ + "type": "object", + "required": ["code", "title", "term"], + "additionalProperties": false, + "properties": { + "code": text("Course code, e.g. BIOSC 1540."), + "title": text("Course title."), + "term": text("Term, e.g., 2026s."), + "institution": { "type": "string" }, + "instructors": string_array("Instructor names."), + "slug": { + "type": "string", + "description": "Short identifier used in file names and administration ids. \ + Derived from the code if omitted." + } + } + }) +} + +/// The schema for the course-wide policy block. +fn policy_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "description": "Course-wide defaults and rules that validation enforces.", + "properties": { + "points_per_item": { + "type": "number", + "exclusiveMinimum": 0.0, + "description": "Default points for an item that does not set its own." + }, + "options_per_item": { + "type": "integer", + "minimum": 2, + "description": "Expected option count; the linter flags items that differ." + }, + "bonus_levels": { + "type": "array", + "items": level(), + "description": "Levels a bonus item may be drawn from." + }, + "allow_partial_credit": { + "type": "boolean", + "description": "Whether any option may carry partial credit. When false, \ + validation rejects items that do." + }, + "partial_credit_floor_level": level(), + "mastery_threshold": proportion( + "Rate at which an objective counts as met. 0.75 is a common choice." + ), + "min_items_for_mastery": { + "type": "integer", + "minimum": 1, + "description": "Below this many items on an objective, reports say 'not enough \ + evidence' rather than classifying." + } + } + }) +} + +/// The schema for one unit. +fn unit_schema() -> Value { + json!({ + "type": "object", + "required": ["id", "title"], + "additionalProperties": false, + "properties": { + "id": text("Unit id."), + "title": text("Unit title."), + "description": { "type": "string" } + } + }) +} + +/// The schema for one lecture. +fn lecture_schema() -> Value { + json!({ + "type": "object", + "required": ["title"], + "additionalProperties": false, + "properties": { + "title": text("Lecture title."), + "date": date("Date delivered."), + "unit": { "type": "string", "description": "Unit id." }, + "slides_url": { "type": "string" }, + "readings": string_array("Readings assigned with this lecture.") + } + }) +} + +/// The schema for one learning objective. +fn objective_schema() -> Value { + json!({ + "type": "object", + "required": ["text"], + "additionalProperties": false, + "properties": { + "text": text("The objective as a student would read it. Start with a verb."), + "unit": { "type": "string" }, + "lectures": string_array("Lecture ids that cover this."), + "level_ceiling": level(), + "prerequisites": string_array( + "Objective ids that must come first. Cycles are rejected." + ), + "tags": string_array("Free-form tags."), + "assessed": { + "type": "boolean", + "description": "Set false for an objective you teach but do not test; coverage \ + reporting will stop flagging it as a gap." + } + } + }) +} + +/// The schema for one shared stimulus. +fn stimulus_schema() -> Value { + json!({ + "type": "object", + "required": ["body"], + "additionalProperties": false, + "properties": { + "body": text("The stimulus text, in coursebank markup."), + "asset": { "type": "string", "description": "Path to an image." }, + "caption": { "type": "string" } + } + }) +} + +/// The course schema. +fn course_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": format!("{BASE}/course.schema.json"), + "title": "coursebank course file", + "description": "Course identity, policy, and the registries that item and assessment \ + files reference by id.", + "type": "object", + "required": ["course"], + "additionalProperties": false, + "properties": { + "schema_version": { + "type": ["string", "number"], + "description": format!("Format version; currently {SCHEMA_VERSION}.") + }, + "course": course_identity_schema(), + "policy": policy_schema(), + "units": { + "type": "array", + "description": "Course units, in teaching order. That order drives report layout.", + "items": unit_schema() + }, + "lectures": { + "type": "object", + "description": "Lectures by id. Items cite these so reports can tell a student \ + where to go back to.", + "additionalProperties": lecture_schema() + }, + "learning_objectives": { + "type": "object", + "description": "Objectives by id. Everything downstream — coverage, mastery, \ + student reports — keys off these.", + "additionalProperties": objective_schema() + }, + "stimuli": { + "type": "object", + "description": "Shared passages, figures, or data that several items refer to.", + "additionalProperties": stimulus_schema() + } + } + }) +} + +/// One option's schema. +/// +/// Split out from [`item_schema`] rather than inlined, because `serde_json`'s +/// `json!` macro recurses once per nesting level *and* once per key-value pair. A +/// single literal describing the whole item exceeded the default recursion limit of +/// 128, so each subtree gets its own shallow invocation. Keeping them small also +/// means adding a field later cannot silently reintroduce the problem. +fn option_schema() -> Value { + json!({ + "type": "object", + "required": ["id", "text"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "pattern": "^[A-H]$", + "description": "Option letter. Identity, not print position — shuffled forms \ + relabel on the way out." + }, + "text": text("The option as a student reads it."), + "correct": { "type": "boolean" }, + "credit": proportion("Partial credit. Requires defensible: true and a defense."), + "explanation": { + "type": "string", + "description": "Why this option is right or wrong. For you, not the student." + }, + "hint": { "type": "string" }, + "misconception": { + "type": "string", + "description": "The specific wrong belief that leads here. This text is what \ + student reports use, so write it as a completion of 'students \ + pick this when ...'." + }, + "error_type": error_type(), + "defensible": { + "type": "boolean", + "description": "This option has a reading under which it is arguably correct. \ + Required before granting partial credit." + }, + "defense": { + "type": "string", + "description": "The argument for that reading. Required when defensible is true." + }, + "feedback_student": { + "type": "string", + "description": "Shown to a student who chose this, on Canvas and in reports." + }, + "selection_rate_expected": proportion( + "How often you expect this to be chosen. Compared against reality." + ) + } + }) +} + +/// The schema for where an item's material was taught. +fn source_schema() -> Value { + json!({ + "type": "object", + "required": ["lecture"], + "additionalProperties": false, + "properties": { + "lecture": text("Lecture id from course.yaml."), + "slides": { "type": "array", "items": { "type": "integer", "minimum": 1 } }, + "readings": string_array("Specific readings."), + "recording_seconds": { "type": "integer", "minimum": 0 } + } + }) +} + +/// The schema for an attached figure. +fn asset_schema() -> Value { + json!({ + "type": "object", + "required": ["path"], + "additionalProperties": false, + "properties": { + "path": text("Path to the image, relative to the course root."), + "alt": { + "type": "string", + "description": "Alt text. Required in practice: the linter flags an asset without \ + it, because an exam question a screen reader cannot convey is not \ + answerable." + }, + "caption": { "type": "string" } + } + }) +} + +/// The schema for authored design intent. +fn design_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "description": "What you expected before giving the item. Kept separate from observed \ + statistics so the two can be compared.", + "properties": { + "expected_difficulty": proportion("Fraction you expect to answer correctly."), + "expected_discrimination": { + "type": "string", + "enum": ["low", "moderate", "high"] + }, + "expected_time_seconds": { "type": "number", "exclusiveMinimum": 0.0 }, + "rationale": { + "type": "string", + "description": "Why this item exists and what it is meant to catch." + } + } + }) +} + +/// The schema for one option's observed statistics. +fn option_stat_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "selection_rate": proportion("How often chosen."), + "point_biserial": { "type": "number", "minimum": -1.0, "maximum": 1.0 }, + "upper_group_rate": proportion("Rate in the top 27%."), + "lower_group_rate": proportion("Rate in the bottom 27%.") + } + }) +} + +/// The schema for fitted IRT parameters. +fn irt_schema() -> Value { + json!({ + "type": "object", + "required": ["a", "b"], + "additionalProperties": false, + "properties": { + "model": { "type": "string", "enum": ["rasch", "2pl", "3pl"] }, + "a": { "type": "number", "description": "Discrimination." }, + "b": { "type": "number", "description": "Difficulty." }, + "c": proportion("Lower asymptote, 3PL only."), + "se_a": { "type": "number", "minimum": 0.0 }, + "se_b": { "type": "number", "minimum": 0.0 }, + "n": { "type": "integer", "minimum": 0 }, + "bayesian": { + "type": "boolean", + "description": "Whether priors were used. On a class-sized sample they should be." + } + } + }) +} + +/// The schema for pooled observed statistics. +fn calibration_schema() -> Value { + let flags: Vec<&str> = Flag::ALL.iter().map(|f| f.as_str()).collect(); + json!({ + "type": "object", + "additionalProperties": false, + "description": "Written by `coursebank calibrate`, not by hand. Pooled across \ + administrations.", + "properties": { + "administrations": string_array("Administration ids pooled here."), + "updated": date("When calibration last ran."), + "fingerprint": { + "type": "string", + "description": "Hash of what a student saw. When it stops matching the item, these \ + statistics describe a different question." + }, + "n_examinees": { "type": "integer", "minimum": 0 }, + "p_value": proportion("Observed proportion correct."), + "point_biserial": { "type": "number", "minimum": -1.0, "maximum": 1.0 }, + "discrimination_index": { "type": "number", "minimum": -1.0, "maximum": 1.0 }, + "mean_response_time_seconds": { "type": "number", "minimum": 0.0 }, + "rapid_guess_rate": proportion("Fraction answered faster than readable."), + "option_stats": { + "type": "object", + "additionalProperties": option_stat_schema() + }, + "irt": irt_schema(), + "flags": { + "type": "array", + "items": { "type": "string", "enum": strings(&flags) } + } + } + }) +} + +/// The schema for a recorded review decision. +fn review_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "reviewed_by": { "type": "string" }, + "reviewed_on": date("Review date."), + "action": { + "type": "string", + "enum": ["keep", "revise", "award_partial_credit", "correct_key", "retire", + "monitor"] + }, + "notes": { "type": "string" } + } + }) +} + +/// The schema for one revision-history entry. +fn history_schema() -> Value { + json!({ + "type": "object", + "required": ["version", "date", "change"], + "additionalProperties": false, + "properties": { + "version": { "type": "integer", "minimum": 1 }, + "date": date("When the change was made."), + "author": { "type": "string" }, + "change": text("What changed and why.") + } + }) +} + +/// The schema for a retirement record. +fn retirement_schema() -> Value { + json!({ + "type": "object", + "required": ["on", "reason"], + "additionalProperties": false, + "properties": { + "on": date("Retirement date."), + "reason": text("Why it was retired."), + "replaced_by": { "type": "string", "description": "Successor item id." } + } + }) +} + +/// The identity and classification half of the item schema. +/// +/// Split from [`item_content_properties`] purely to keep each `json!` invocation +/// short; the two are merged into one `properties` object by [`item_schema`]. +fn item_identity_properties() -> Value { + json!({ + "id": { + "type": "string", + "pattern": "^q-[a-z0-9]+(-[a-z0-9]+)*-[0-9]{3}$", + "description": "Item id, e.g. q-glycolysis-014. Stable forever: assessment records \ + and stored responses refer to it." + }, + "version": { + "type": "integer", + "minimum": 1, + "description": "Bump when you change what a student sees. Recorded on every \ + administration so drift is detectable." + }, + "status": { + "type": "string", + "enum": ["draft", "in_review", "needs_revision", "approved", "retired"], + "description": "Only approved items can be drawn into an assessment." + }, + "level": level(), + "cognitive_process": cognitive_process(), + "format": { + "type": "string", + "enum": ["single_best_answer", "multiple_response", "true_false"], + "description": "single_best_answer requires exactly one keyed option; \ + multiple_response requires at least two." + }, + "bonus": { "type": "boolean" }, + "points": { "type": "number", "exclusiveMinimum": 0.0 }, + "author": { "type": "string" }, + "notes_private": { + "type": "string", + "description": "Never exported anywhere a student can see." + } + }) +} + +/// The content and evidence half of the item schema. +fn item_content_properties() -> Value { + json!({ + "title": { + "type": "string", + "description": "Short internal label. Never shown to students." + }, + "stimulus": { "type": "string", "description": "Stimulus id from course.yaml." }, + "stem": text( + "The question. Ask something specific; the linter flags stems with no task in them." + ), + "options": { + "type": "array", + "minItems": 2, + "maxItems": 8, + "items": option_schema() + }, + "learning_objectives": string_array( + "Objective ids this item measures. Reports aggregate on these, so an item with none \ + contributes to nothing." + ), + "sources": { + "type": "array", + "description": "Where the material was taught. Drives the 'review this' lines in \ + student reports.", + "items": source_schema() + }, + "topics": string_array("Free-form topics, used for blueprint filtering."), + "prerequisites": string_array("Objective ids a student needs before this item."), + "assets": { "type": "array", "items": asset_schema() }, + "design": design_schema(), + "calibration": calibration_schema(), + "review": review_schema(), + "history": { + "type": "array", + "description": "One entry per version. Versions must increase.", + "items": history_schema() + }, + "retired": retirement_schema() + }) +} + +/// The item schema fragment, shared by the bank schema. +/// +/// Assembled from the helpers above rather than written as one literal. See +/// [`option_schema`] for why. +fn item_schema() -> Value { + let mut properties = serde_json::Map::new(); + for half in [item_identity_properties(), item_content_properties()] { + if let Value::Object(map) = half { + properties.extend(map); + } + } + json!({ + "type": "object", + "required": ["id", "level", "stem", "options"], + "additionalProperties": false, + "properties": Value::Object(properties) + }) +} + +/// The schema for a bank's `bank` metadata block. +fn bank_meta_schema() -> Value { + json!({ + "type": "object", + "required": ["id", "title"], + "additionalProperties": false, + "properties": { + "id": text("Bank id, unique within the course."), + "title": text("Human-readable title."), + "description": { "type": "string" }, + "scope": bank_scope_schema() + } + }) +} + +/// The schema for what a bank is meant to cover. +fn bank_scope_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "description": "What this bank is meant to cover. Validation warns when an item strays \ + outside it.", + "properties": { + "lectures": string_array("Lecture ids."), + "learning_objectives": string_array("Objective ids."), + "units": string_array("Unit ids."), + "topics": string_array("Topics.") + } + }) +} + +/// The schema for per-file item defaults. +fn bank_defaults_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "description": "Applied to every item in the file that does not set the field. Saves \ + repeating yourself; the item always wins.", + "properties": { + "author": { "type": "string" }, + "points": { "type": "number", "exclusiveMinimum": 0.0 }, + "options_per_item": { "type": "integer", "minimum": 2 }, + "topics": string_array("Topics added to every item."), + "lectures": string_array("Lecture ids for items with no sources of their own.") + } + }) +} + +/// The bank schema. +fn bank_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": format!("{BASE}/bank.schema.json"), + "title": "coursebank item bank", + "description": "A collection of items. Split banks by unit or topic; ids must be unique \ + across the whole course, not just within a file.", + "type": "object", + "required": ["bank", "items"], + "additionalProperties": false, + "properties": { + "schema_version": { "type": ["string", "number"] }, + "bank": bank_meta_schema(), + "defaults": bank_defaults_schema(), + "items": { "type": "array", "items": item_schema() } + } + }) +} + +/// The schema for the `assessment` metadata block. +fn assessment_meta_schema() -> Value { + json!({ + "type": "object", + "required": ["id", "title"], + "additionalProperties": false, + "properties": { + "id": text("Assessment id, e.g. exam-4. Used in administration ids."), + "title": text("Printed title."), + "term": { "type": "string", "description": "Defaults to the course term." }, + "kind": { + "type": "string", + "enum": ["exam", "quiz", "homework", "practice", "final"], + "description": "practice is excluded from calibration by default, since \ + conditions differ too much to pool." + }, + "date": date("Administration date. Drives reuse cooldowns."), + "platform": { "type": "string", "enum": ["paper", "canvas", "other"] }, + "minutes_allowed": { "type": "number", "exclusiveMinimum": 0.0 }, + "attempts": { + "type": "integer", + "description": "Canvas attempt limit; -1 for unlimited." + }, + "shuffle": { "type": "boolean" }, + "scoring_policy": { "type": "string", "enum": ["keep_highest", "keep_latest"] }, + "instructions": { "type": "string" }, + "notes": { "type": "string" } + } + }) +} + +/// The schema for the blueprint an assessment was drawn to. +fn blueprint_schema() -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "description": "The design the form was drawn to satisfy. Kept so the form can be checked \ + against the intent afterward.", + "properties": { + "level_counts": { + "type": "object", + "description": "How many scored items at each level, keyed by level number.", + "additionalProperties": { "type": "integer", "minimum": 0 } + }, + "bonus_counts": { + "type": "object", + "additionalProperties": { "type": "integer", "minimum": 0 } + }, + "objective_minimums": { + "type": "object", + "description": "Minimum items per objective. Placed before level quotas, because \ + a coverage requirement is the constraint most likely to become \ + unsatisfiable.", + "additionalProperties": { "type": "integer", "minimum": 0 } + }, + "lectures": string_array("Restrict the draw to these lectures."), + "topics": string_array("Restrict the draw to these topics."), + "banks": string_array("Restrict the draw to these banks."), + "max_per_bank": { "type": "integer", "minimum": 1 }, + "cooldown_days": { + "type": "integer", + "minimum": 0, + "description": "Avoid items used within this many days. Relaxed with a warning \ + rather than failing the draw." + }, + "seed": { + "type": "integer", + "minimum": 0, + "description": "Makes the draw reproducible." + } + } + }) +} + +/// The schema for one alternate form. +fn form_schema() -> Value { + json!({ + "type": "object", + "required": ["id", "seed"], + "additionalProperties": false, + "properties": { + "id": text("Form label, e.g. A."), + "seed": { "type": "integer", "minimum": 0 }, + "shuffle_items": { "type": "boolean" }, + "shuffle_options": { "type": "boolean" } + } + }) +} + +/// The schema for one question placement. +fn placement_schema() -> Value { + json!({ + "type": "object", + "required": ["number", "item"], + "additionalProperties": false, + "properties": { + "number": { + "type": "integer", + "minimum": 1, + "description": "The question number as administered. This is the join key to \ + Gradescope and Canvas exports, so it must not change after the \ + fact." + }, + "item": text("Item reference, `bank::item-id` or a bare item id."), + "version": { "type": "integer", "minimum": 1 }, + "fingerprint": { + "type": "string", + "description": "What the item looked like when given. Validation warns if the item \ + has since changed." + }, + "points": { "type": "number", "minimum": 0.0 }, + "bonus": { "type": "boolean" }, + "key": string_array("Keyed option letters as administered."), + "level": level(), + "learning_objectives": string_array("Objectives as administered."), + "credit_overrides": { + "type": "object", + "description": "Partial credit decided after the fact, by option letter. Recording \ + it here keeps the rescoring decision with the administration it \ + applies to.", + "additionalProperties": { "type": "number", "minimum": 0.0, "maximum": 1.0 } + }, + "dropped": { + "type": "boolean", + "description": "Excluded from scoring and from statistics." + } + } + }) +} + +/// The assessment schema. +fn assessment_schema() -> Value { + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": format!("{BASE}/assessment.schema.json"), + "title": "coursebank assessment record", + "description": "A record of what was given, to whom, and when. This is the join between \ + item banks and grading data, and it is the source of truth for reuse \ + history — there is no separate ledger to drift out of step.", + "type": "object", + "required": ["assessment"], + "additionalProperties": false, + "properties": { + "schema_version": { "type": ["string", "number"] }, + "assessment": assessment_meta_schema(), + "blueprint": blueprint_schema(), + "forms": { + "type": "array", + "description": "Alternate forms. Option order is derived from the seed rather than \ + stored, so every export of a form agrees.", + "items": form_schema() + }, + "items": { + "type": "array", + "description": "One entry per question, in number order.", + "items": placement_schema() + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_schema_is_well_formed() { + for kind in Kind::ALL { + let s = schema(kind); + assert!(s["$schema"].is_string(), "{kind:?} needs a $schema"); + assert!(s["$id"].is_string(), "{kind:?} needs an $id"); + assert_eq!(s["type"], "object"); + assert!( + s["additionalProperties"] == false, + "{kind:?} must reject unknown keys, matching the Rust deserializer" + ); + // Round-trips as JSON. + let text = serde_json::to_string(&s).unwrap(); + let back: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(back, s); + } + } + + #[test] + fn the_level_enum_lists_all_five() { + let l = level(); + assert_eq!(l["minimum"], 1); + assert_eq!(l["maximum"], 5); + let description = l["description"].as_str().unwrap(); + for level in Level::ALL { + assert!( + description.contains(level.name()), + "missing {}", + level.name() + ); + } + } + + #[test] + fn the_process_enum_matches_the_taxonomy() { + let p = cognitive_process(); + let listed = p["enum"].as_array().unwrap(); + assert_eq!(listed.len(), CognitiveProcess::ALL.len()); + assert!(listed.contains(&Value::String("differentiate".into()))); + } + + #[test] + fn the_item_schema_constrains_ids_and_options() { + let item = item_schema(); + let props = &item["properties"]; + assert!(props["id"]["pattern"].as_str().unwrap().starts_with("^q-")); + assert_eq!(props["options"]["minItems"], 2); + assert_eq!(props["options"]["maxItems"], 8); + assert_eq!( + props["options"]["items"]["properties"]["id"]["pattern"], + "^[A-H]$" + ); + } + + #[test] + fn modelines_point_at_the_right_file() { + assert_eq!( + Kind::Bank.modeline("../.coursebank/schema"), + "# yaml-language-server: $schema=../.coursebank/schema/bank.schema.json" + ); + // A trailing slash must not double up. + assert!( + Kind::Course + .modeline("schema/") + .ends_with("schema/course.schema.json") + ); + } + + #[test] + fn schemas_write_to_disk() { + let dir = std::env::temp_dir().join(format!("cb-schema-{}", std::process::id())); + std::fs::remove_dir_all(&dir).ok(); + let written = write_all(&dir).unwrap(); + assert_eq!(written.len(), 3); + for path in &written { + assert!(path.exists()); + let text = std::fs::read_to_string(path).unwrap(); + let _: Value = serde_json::from_str(&text).expect("valid JSON on disk"); + } + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/src/authoring/lint.rs b/src/authoring/lint.rs new file mode 100644 index 0000000..040c613 --- /dev/null +++ b/src/authoring/lint.rs @@ -0,0 +1,1465 @@ +// SPDX-License-Identifier: Prosperity-3.0.0 +// Copyright Scientific Computing Studio +// Source: https://git.scient.ing/education/coursebank + +//! Authoring-quality checks: is this item *well written*? +//! +//! Validation asks whether a file is usable. Linting asks the harder question, +//! and the answers are advisory: a lint finding is a prompt to look, not a +//! verdict. Some rules will fire on items you meant to write that way, which is +//! why every rule has a code you can silence. +//! +//! The rules fall into four families, and it is worth knowing which is which +//! because they have different reliability. +//! +//! *Cueing* rules are the most valuable, because they catch items that measure +//! test-taking rather than learning. If the key is reliably the longest option, a +//! student who knows nothing can beat the item. These rules are mechanical and +//! trustworthy. +//! +//! *Clarity* rules look for stems that do not pose a definite task, unemphasized +//! negation, and prose that reads well above the level of the course. They catch +//! real problems and also produce the most false positives. +//! +//! *Completeness* rules check that distractors are designed rather than filler, +//! and that there is something to say to a student who picks one. They are what +//! make the reporting features possible at all. +//! +//! *Evidence* rules compare what you predicted against what happened, and flag +//! statistics that describe a version of the item you have since edited. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::catalog::{Catalog, Entry, Severity}; +use crate::course::CourseFile; +use crate::item::Item; +use crate::taxonomy::{Discrimination, Format, Level, Status}; + +/// A lint rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Rule { + // --- cueing --- + /// The keyed option is conspicuously longer than the distractors. + KeyIsLongest, + /// Option lengths vary so much that length itself is informative. + UnevenOptionLength, + /// A distinctive word appears in the stem and only in the key. + WordRepeatCue, + /// An "all of the above" or "none of the above" option. + AllOrNoneOfTheAbove, + /// A distractor stated in absolute terms, which trained students discard. + AbsoluteDistractor, + /// One option's text is contained in another's. + OverlappingOptions, + /// Two options say close to the same thing. + NearDuplicateOptions, + /// The keyed letter is imbalanced across the bank. + KeyPositionImbalance, + /// An item's option count differs from its neighbors'. + InconsistentOptionCount, + + // --- clarity --- + /// The stem does not pose a definite question or completion. + StemHasNoTask, + /// The stem is long enough that reading load competes with the construct. + StemTooLong, + /// Negation is present but not emphasized. + UnemphasizedNegation, + /// A vague quantifier that makes more than one option defensible. + VagueQualifier, + /// The prose reads well above the expected level. + HighReadingLoad, + /// A stem that asks which statement is "true" without a focus. + UnfocusedStem, + + // --- completeness --- + /// A distractor with no named misconception or error type. + UndesignedDistractor, + /// A distractor with no explanation. + UnexplainedDistractor, + /// Nothing to show a student who chose this option. + NoStudentFeedback, + /// A figure with no alt text. + AssetWithoutAltText, + /// True/False used at an analytic level, where it carries little signal. + WeakFormatForLevel, + /// A level-5 item scored in the graded total against course policy. + ScoredBonusLevel, + /// An expectation of low discrimination on a higher-level item. + ContradictoryDesign, + + // --- evidence --- + /// Statistics describe an older version of the item. + StaleCalibration, + /// Observed difficulty was far from predicted difficulty. + DifficultyMissed, + /// Observed discrimination contradicted the prediction. + DiscriminationMissed, + /// Two items in the course have nearly the same stem. + DuplicateStem, +} + +impl Rule { + /// Every rule, grouped as the families appear above. + /// + /// Used by `--list-rules`, and by the test that keeps this list in step with + /// the enum. + pub const ALL: [Rule; 26] = [ + Rule::KeyIsLongest, + Rule::UnevenOptionLength, + Rule::WordRepeatCue, + Rule::AllOrNoneOfTheAbove, + Rule::AbsoluteDistractor, + Rule::OverlappingOptions, + Rule::NearDuplicateOptions, + Rule::KeyPositionImbalance, + Rule::InconsistentOptionCount, + Rule::StemHasNoTask, + Rule::StemTooLong, + Rule::UnemphasizedNegation, + Rule::VagueQualifier, + Rule::HighReadingLoad, + Rule::UnfocusedStem, + Rule::UndesignedDistractor, + Rule::UnexplainedDistractor, + Rule::NoStudentFeedback, + Rule::AssetWithoutAltText, + Rule::WeakFormatForLevel, + Rule::ScoredBonusLevel, + Rule::ContradictoryDesign, + Rule::StaleCalibration, + Rule::DifficultyMissed, + Rule::DiscriminationMissed, + Rule::DuplicateStem, + ]; + + /// The rule's nominal severity, for `--list-rules` and `--min-severity`. + /// + /// A finding may carry a different severity than its rule's nominal one when + /// the specific case is worse than usual — an unevenly sized option set is + /// low by default but medium when the spread is extreme. This value is the + /// rule's typical weight, which is what a listing should show. + /// + /// The three cueing rules that rise to high are the ones a test-wise student + /// can exploit without knowing the material, which makes them scoring bugs + /// rather than style notes. + pub fn severity(self) -> Severity { + use Rule as R; + match self { + // A student who can find the key without the content is not being + // measured on the content. + R::KeyIsLongest | R::WordRepeatCue => Severity::High, + // Statistics attached to text that has since changed are actively + // misleading, which is worse than absent. + R::StaleCalibration => Severity::High, + // An unanswerable question for a screen-reader user. + R::AssetWithoutAltText => Severity::High, + + R::AllOrNoneOfTheAbove + | R::OverlappingOptions + | R::NearDuplicateOptions + | R::KeyPositionImbalance + | R::StemHasNoTask + | R::UnemphasizedNegation + | R::VagueQualifier + | R::UnfocusedStem + | R::UndesignedDistractor + | R::WeakFormatForLevel + | R::ScoredBonusLevel + | R::ContradictoryDesign + | R::DuplicateStem => Severity::Medium, + + R::UnevenOptionLength + | R::AbsoluteDistractor + | R::InconsistentOptionCount + | R::StemTooLong + | R::HighReadingLoad + | R::UnexplainedDistractor + | R::NoStudentFeedback + | R::DifficultyMissed + | R::DiscriminationMissed => Severity::Low, + } + } + + /// The stable code used to silence the rule from the command line. + pub fn code(self) -> &'static str { + match self { + Rule::KeyIsLongest => "cue-key-longest", + Rule::UnevenOptionLength => "cue-uneven-length", + Rule::WordRepeatCue => "cue-word-repeat", + Rule::AllOrNoneOfTheAbove => "cue-all-of-the-above", + Rule::AbsoluteDistractor => "cue-absolute", + Rule::OverlappingOptions => "cue-overlap", + Rule::NearDuplicateOptions => "cue-near-duplicate", + Rule::KeyPositionImbalance => "cue-key-position", + Rule::InconsistentOptionCount => "cue-option-count", + Rule::StemHasNoTask => "clarity-no-task", + Rule::StemTooLong => "clarity-stem-length", + Rule::UnemphasizedNegation => "clarity-negation", + Rule::VagueQualifier => "clarity-vague", + Rule::HighReadingLoad => "clarity-reading-load", + Rule::UnfocusedStem => "clarity-unfocused", + Rule::UndesignedDistractor => "complete-distractor-design", + Rule::UnexplainedDistractor => "complete-distractor-explanation", + Rule::NoStudentFeedback => "complete-student-feedback", + Rule::AssetWithoutAltText => "complete-alt-text", + Rule::WeakFormatForLevel => "complete-format-level", + Rule::ScoredBonusLevel => "complete-bonus-policy", + Rule::ContradictoryDesign => "complete-design-conflict", + Rule::StaleCalibration => "evidence-stale", + Rule::DifficultyMissed => "evidence-difficulty", + Rule::DiscriminationMissed => "evidence-discrimination", + Rule::DuplicateStem => "evidence-duplicate-stem", + } + } + + /// Which family the rule belongs to. + pub fn family(self) -> &'static str { + match self { + Rule::KeyIsLongest + | Rule::UnevenOptionLength + | Rule::WordRepeatCue + | Rule::AllOrNoneOfTheAbove + | Rule::AbsoluteDistractor + | Rule::OverlappingOptions + | Rule::NearDuplicateOptions + | Rule::KeyPositionImbalance + | Rule::InconsistentOptionCount => "cueing", + Rule::StemHasNoTask + | Rule::StemTooLong + | Rule::UnemphasizedNegation + | Rule::VagueQualifier + | Rule::HighReadingLoad + | Rule::UnfocusedStem => "clarity", + Rule::UndesignedDistractor + | Rule::UnexplainedDistractor + | Rule::NoStudentFeedback + | Rule::AssetWithoutAltText + | Rule::WeakFormatForLevel + | Rule::ScoredBonusLevel + | Rule::ContradictoryDesign => "completeness", + Rule::StaleCalibration + | Rule::DifficultyMissed + | Rule::DiscriminationMissed + | Rule::DuplicateStem => "evidence", + } + } + + /// Every rule, for `coursebank lint --list-rules`. + pub fn all() -> Vec { + vec![ + Rule::KeyIsLongest, + Rule::UnevenOptionLength, + Rule::WordRepeatCue, + Rule::AllOrNoneOfTheAbove, + Rule::AbsoluteDistractor, + Rule::OverlappingOptions, + Rule::NearDuplicateOptions, + Rule::KeyPositionImbalance, + Rule::InconsistentOptionCount, + Rule::StemHasNoTask, + Rule::StemTooLong, + Rule::UnemphasizedNegation, + Rule::VagueQualifier, + Rule::HighReadingLoad, + Rule::UnfocusedStem, + Rule::UndesignedDistractor, + Rule::UnexplainedDistractor, + Rule::NoStudentFeedback, + Rule::AssetWithoutAltText, + Rule::WeakFormatForLevel, + Rule::ScoredBonusLevel, + Rule::ContradictoryDesign, + Rule::StaleCalibration, + Rule::DifficultyMissed, + Rule::DiscriminationMissed, + Rule::DuplicateStem, + ] + } + + /// What the rule is looking for, one line. + pub fn description(self) -> &'static str { + match self { + Rule::KeyIsLongest => "the key is much longer than every distractor", + Rule::UnevenOptionLength => "option lengths differ enough to be a cue", + Rule::WordRepeatCue => "a distinctive stem word appears only in the key", + Rule::AllOrNoneOfTheAbove => "an all-of-the-above or none-of-the-above option", + Rule::AbsoluteDistractor => "a distractor phrased in absolutes", + Rule::OverlappingOptions => "one option contains another", + Rule::NearDuplicateOptions => "two options are near duplicates", + Rule::KeyPositionImbalance => "keyed letters are unevenly distributed in the bank", + Rule::InconsistentOptionCount => "option count differs from the bank default", + Rule::StemHasNoTask => "the stem poses no definite task", + Rule::StemTooLong => "the stem is very long", + Rule::UnemphasizedNegation => "negation is not emphasized", + Rule::VagueQualifier => "a vague quantifier makes several options defensible", + Rule::HighReadingLoad => "reading level is well above the course", + Rule::UnfocusedStem => "the stem asks which statement is true, without a focus", + Rule::UndesignedDistractor => "a distractor names no misconception or error type", + Rule::UnexplainedDistractor => "a distractor has no explanation", + Rule::NoStudentFeedback => "nothing to show a student who chose this option", + Rule::AssetWithoutAltText => "a figure has no alt text", + Rule::WeakFormatForLevel => "true/false at an analytic level", + Rule::ScoredBonusLevel => "a level the policy reserves for bonus is scored", + Rule::ContradictoryDesign => "low expected discrimination on a higher-level item", + Rule::StaleCalibration => "statistics describe an older version of the item", + Rule::DifficultyMissed => "observed difficulty was far from predicted", + Rule::DiscriminationMissed => "observed discrimination contradicted the prediction", + Rule::DuplicateStem => "two items have nearly the same stem", + } + } +} + +/// One lint finding. +#[derive(Debug, Clone)] +pub struct Finding { + /// The item's global id, or a bank id for bank-wide findings. + pub subject: String, + /// The rule that fired. + pub rule: Rule, + /// How much attention it deserves. + pub severity: Severity, + /// What was found, and where. + pub message: String, +} + +/// Thresholds, so the judgment calls are visible and adjustable rather than +/// buried as literals in the middle of a function. +#[derive(Debug, Clone)] +pub struct Thresholds { + /// Stem word count above which the stem is called long. + pub stem_words: usize, + /// Ratio of longest to mean distractor length that counts as a length cue. + pub key_length_ratio: f64, + /// Ratio of longest to shortest option that counts as uneven. + pub option_spread_ratio: f64, + /// Jaccard similarity above which two options are near duplicates. + pub option_similarity: f64, + /// Jaccard similarity above which two stems are near duplicates. + pub stem_similarity: f64, + /// Flesch-Kincaid grade above which reading load is flagged. + pub reading_grade: f64, + /// The proportion of a bank's keys any one letter may hold. + pub key_share: f64, + /// The fewest items in a bank before key position is worth testing. + pub key_position_min_items: usize, + /// Absolute difference between predicted and observed difficulty that counts + /// as a missed prediction. + pub difficulty_tolerance: f64, +} + +impl Default for Thresholds { + fn default() -> Thresholds { + Thresholds { + stem_words: 70, + key_length_ratio: 1.5, + option_spread_ratio: 3.0, + option_similarity: 0.8, + stem_similarity: 0.85, + reading_grade: 16.0, + key_share: 0.4, + key_position_min_items: 10, + difficulty_tolerance: 0.25, + } + } +} + +/// Lints every item in a course, plus the bank-wide and course-wide rules. +/// +/// # Arguments +/// +/// * `catalog` - the loaded course. +/// * `t` - thresholds. +/// +/// # Returns +/// +/// Findings, ordered by severity then subject so the important ones come first. +pub fn lint_catalog(catalog: &Catalog, t: &Thresholds) -> Vec { + let mut out = Vec::new(); + + for entry in &catalog.entries { + if entry.item.status == Status::Retired { + continue; + } + out.extend(lint_item(entry, &catalog.course, t)); + } + + out.extend(lint_key_positions(catalog, t)); + out.extend(lint_option_counts(catalog)); + out.extend(lint_duplicate_stems(catalog, t)); + + out.sort_by(|a, b| { + b.severity + .cmp(&a.severity) + .then(a.subject.cmp(&b.subject)) + .then(a.rule.cmp(&b.rule)) + }); + out +} + +/// Lints one item. +/// +/// # Arguments +/// +/// * `entry` - the catalog entry. +/// * `course` - the course, for policy and expected reading level. +/// * `t` - thresholds. +/// +/// # Returns +/// +/// Findings for this item. +pub fn lint_item(entry: &Entry, course: &CourseFile, t: &Thresholds) -> Vec { + let it = &entry.item; + let uid = entry.uid.clone(); + let mut f = Vec::new(); + let mut push = |rule: Rule, severity: Severity, message: String| { + f.push(Finding { + subject: uid.clone(), + rule, + severity, + message, + }); + }; + + // ---------------------------------------------------------------- clarity + let stem = it.stem.trim(); + let stem_lower = stem.to_lowercase(); + let words: Vec<&str> = stem.split_whitespace().collect(); + + if !stem.contains('?') && !stem.ends_with(':') && !stem.ends_with("___") { + push( + Rule::StemHasNoTask, + Severity::Medium, + "the stem neither asks a question nor sets up a completion; a student \ + has to infer what is being asked" + .to_string(), + ); + } + if words.len() > t.stem_words { + push( + Rule::StemTooLong, + Severity::Low, + format!( + "the stem runs {} words; consider moving background into a shared stimulus", + words.len() + ), + ); + } + + // Negation is legitimate but must be visible. Emphasis means the word is + // uppercase or wrapped in markup. + for neg in ["not", "except", "least", "never", "incorrect", "false"] { + if contains_word(&stem_lower, neg) && !negation_is_emphasized(stem, neg) { + push( + Rule::UnemphasizedNegation, + Severity::Medium, + format!( + "the stem turns on `{neg}` without emphasis; students skim past it. \ + Write it as `{}` or bold it.", + neg.to_uppercase() + ), + ); + break; + } + } + + for vague in [ + "often", + "usually", + "generally", + "sometimes", + "may", + "might", + "several", + "many", + "frequently", + "typically", + ] { + if contains_word(&stem_lower, vague) { + push( + Rule::VagueQualifier, + Severity::Low, + format!( + "`{vague}` in the stem can make more than one option defensible; \ + pin the condition down" + ), + ); + break; + } + } + + if stem_lower.contains("which of the following is true") + || stem_lower.contains("which statement is true") + || stem_lower.contains("which of the following statements is correct") + { + push( + Rule::UnfocusedStem, + Severity::Medium, + "an unfocused `which is true` stem tests scanning rather than a single idea; \ + name the concept the item is about" + .to_string(), + ); + } + + let grade = flesch_kincaid_grade(stem); + if grade > t.reading_grade { + push( + Rule::HighReadingLoad, + Severity::Low, + format!( + "stem reads at about grade {grade:.0}; long sentences add reading load \ + that is not part of what you are measuring" + ), + ); + } + + // ----------------------------------------------------------------- cueing + let keys: Vec<&crate::item::Choice> = it.options.iter().filter(|o| o.correct).collect(); + let distractors: Vec<&crate::item::Choice> = it.options.iter().filter(|o| !o.correct).collect(); + + if !keys.is_empty() && !distractors.is_empty() { + let key_len = keys + .iter() + .map(|o| o.text.trim().chars().count()) + .max() + .unwrap_or(0) as f64; + let mean_distractor = distractors + .iter() + .map(|o| o.text.trim().chars().count() as f64) + .sum::() + / distractors.len() as f64; + if mean_distractor > 0.0 && key_len / mean_distractor >= t.key_length_ratio { + push( + Rule::KeyIsLongest, + Severity::High, + format!( + "the key is {:.1}x the average distractor length ({key_len:.0} vs \ + {mean_distractor:.0} characters); a test-wise student can pick it \ + without knowing the content", + key_len / mean_distractor + ), + ); + } + } + + let lens: Vec = it + .options + .iter() + .map(|o| o.text.trim().chars().count().max(1)) + .collect(); + if let (Some(&mx), Some(&mn)) = (lens.iter().max(), lens.iter().min()) { + if mn > 0 && (mx as f64) / (mn as f64) >= t.option_spread_ratio { + push( + Rule::UnevenOptionLength, + Severity::Low, + format!("option lengths run {mn} to {mx} characters; even them out"), + ); + } + } + + // A distinctive word shared by the stem and the key alone is a clang cue. + if let Some(word) = repeated_cue_word(it) { + push( + Rule::WordRepeatCue, + Severity::Medium, + format!( + "`{word}` appears in the stem and in the key but in no distractor; \ + the echo points at the answer" + ), + ); + } + + for o in &it.options { + let low = o.text.trim().to_lowercase(); + if low.starts_with("all of the above") + || low.starts_with("none of the above") + || low.starts_with("both a and b") + || low == "a and b" + || low == "all of these" + || low == "none of these" + { + push( + Rule::AllOrNoneOfTheAbove, + Severity::Medium, + format!( + "option {} is `{}`; partial knowledge answers it and shuffling \ + answers in Canvas breaks it", + o.id, + o.text.trim() + ), + ); + } + if !o.correct { + for abs in [ + "always", + "never", + "all ", + "none ", + "every ", + "no exceptions", + ] { + if low.contains(abs) { + push( + Rule::AbsoluteDistractor, + Severity::Low, + format!( + "distractor {} is phrased absolutely (`{}`); students discard \ + absolutes on principle", + o.id, + abs.trim() + ), + ); + break; + } + } + } + } + + for i in 0..it.options.len() { + for j in (i + 1)..it.options.len() { + let a = it.options[i].text.trim().to_lowercase(); + let b = it.options[j].text.trim().to_lowercase(); + if a.is_empty() || b.is_empty() { + continue; + } + if a != b && (a.contains(&b) || b.contains(&a)) { + push( + Rule::OverlappingOptions, + Severity::Medium, + format!( + "option {} contains option {}; one cannot be right without the other", + if a.contains(&b) { + &it.options[i].id + } else { + &it.options[j].id + }, + if a.contains(&b) { + &it.options[j].id + } else { + &it.options[i].id + } + ), + ); + } else { + let sim = jaccard(&tokens(&a), &tokens(&b)); + if sim >= t.option_similarity { + push( + Rule::NearDuplicateOptions, + Severity::Medium, + format!( + "options {} and {} are {:.0}% the same; they are not two \ + distinct ideas", + it.options[i].id, + it.options[j].id, + sim * 100.0 + ), + ); + } + } + } + } + + // ----------------------------------------------------------- completeness + // These are only worth insisting on once an item is meant to be used. + let is_ready = matches!(it.status, Status::Approved | Status::InReview); + if is_ready { + for o in &it.options { + if !o.correct { + if o.misconception.is_none() && o.error_type.is_none() { + push( + Rule::UndesignedDistractor, + Severity::Medium, + format!( + "distractor {} names no misconception or error_type; if you \ + cannot say what mistake it captures, it is filler and its \ + selection rate will tell you nothing", + o.id + ), + ); + } + if o.explanation.is_none() { + push( + Rule::UnexplainedDistractor, + Severity::Low, + format!("distractor {} has no explanation", o.id), + ); + } + } + if o.student_text().is_none() { + push( + Rule::NoStudentFeedback, + Severity::Low, + format!( + "option {} carries no text a post-exam report could show a \ + student who chose it", + o.id + ), + ); + } + } + } + + for a in &it.assets { + if a.alt + .as_deref() + .map(|s| s.trim().is_empty()) + .unwrap_or(true) + { + push( + Rule::AssetWithoutAltText, + Severity::Medium, + format!("asset `{}` has no alt text", a.path), + ); + } + } + + if it.format == Format::TrueFalse && it.level >= Level::Analyze { + push( + Rule::WeakFormatForLevel, + Severity::Low, + format!( + "true/false at level {} gives a 50% floor and little diagnostic signal", + it.level.code() + ), + ); + } + + if course.policy.bonus_levels.contains(&it.level) && !it.bonus { + push( + Rule::ScoredBonusLevel, + Severity::Medium, + format!( + "the course policy reserves level {} for bonus items, but this one is scored", + it.level.code() + ), + ); + } + + if let Some(d) = &it.design { + if d.expected_discrimination == Some(Discrimination::Low) && it.level >= Level::Apply { + push( + Rule::ContradictoryDesign, + Severity::Low, + format!( + "a level {} item expected to discriminate poorly is doing the work of \ + a level 1 anchor; check the level or the expectation", + it.level.code() + ), + ); + } + } + + // --------------------------------------------------------------- evidence + if !it.calibration_is_current() { + push( + Rule::StaleCalibration, + Severity::High, + "the item was edited after it was calibrated, so its statistics and IRT \ + parameters describe a question you no longer ask" + .to_string(), + ); + } + + if let (Some(design), Some(cal)) = (&it.design, &it.calibration) { + if let (Some(expected), Some(observed)) = (design.expected_difficulty, cal.p_value) { + let gap = (expected - observed).abs(); + if gap >= t.difficulty_tolerance { + push( + Rule::DifficultyMissed, + Severity::Medium, + format!( + "you predicted {:.0}% correct and observed {:.0}%; either the \ + cohort or the item is not what you thought", + expected * 100.0, + observed * 100.0 + ), + ); + } + } + if let (Some(expected), Some(observed)) = + (design.expected_discrimination, cal.point_biserial) + { + let (lo, hi) = expected.expected_band(); + if observed < lo || observed > hi { + push( + Rule::DiscriminationMissed, + Severity::Low, + format!( + "expected {:?} discrimination (roughly {lo:.2} to {hi:.2}) but \ + observed a point-biserial of {observed:.2}", + expected + ), + ); + } + } + } + + f +} + +/// Flags banks whose keyed letters cluster on one position. +/// +/// Instructors reach for C. Students know it. This is a bank-level rule because +/// a single item cannot be imbalanced. +/// +/// # Arguments +/// +/// * `catalog` - the loaded course. +/// * `t` - thresholds. +/// +/// # Returns +/// +/// One finding per offending bank. +fn lint_key_positions(catalog: &Catalog, t: &Thresholds) -> Vec { + let mut by_bank: BTreeMap<&str, Vec<&Entry>> = BTreeMap::new(); + for e in &catalog.entries { + if e.item.status == Status::Retired || e.item.is_multi_key() { + continue; + } + by_bank.entry(e.bank.as_str()).or_default().push(e); + } + + let mut out = Vec::new(); + for (bank, entries) in by_bank { + if entries.len() < t.key_position_min_items { + continue; + } + let mut counts: BTreeMap = BTreeMap::new(); + for e in &entries { + for letter in e.item.key_letters() { + *counts.entry(letter).or_insert(0) += 1; + } + } + let total: usize = counts.values().sum(); + if total == 0 { + continue; + } + for (letter, n) in &counts { + let share = *n as f64 / total as f64; + if share > t.key_share { + out.push(Finding { + subject: bank.to_string(), + rule: Rule::KeyPositionImbalance, + severity: Severity::Medium, + message: format!( + "{:.0}% of keys in this bank are `{letter}` ({n} of {total}); \ + shuffle at export time or rebalance while authoring", + share * 100.0 + ), + }); + } + } + } + out +} + +/// Flags items whose option count differs from the mode of their bank. +/// +/// # Arguments +/// +/// * `catalog` - the loaded course. +/// +/// # Returns +/// +/// One finding per odd item. +fn lint_option_counts(catalog: &Catalog) -> Vec { + let mut by_bank: BTreeMap<&str, Vec<&Entry>> = BTreeMap::new(); + for e in &catalog.entries { + if e.item.status == Status::Retired { + continue; + } + by_bank.entry(e.bank.as_str()).or_default().push(e); + } + + let mut out = Vec::new(); + for (_, entries) in by_bank { + if entries.len() < 4 { + continue; + } + let mut counts: BTreeMap = BTreeMap::new(); + for e in &entries { + *counts.entry(e.item.options.len()).or_insert(0) += 1; + } + let modal = counts + .iter() + .max_by_key(|(_, n)| **n) + .map(|(k, _)| *k) + .unwrap_or(0); + // Only complain when there is a clear norm to deviate from. + if counts.get(&modal).copied().unwrap_or(0) * 2 <= entries.len() { + continue; + } + for e in &entries { + if e.item.options.len() != modal { + out.push(Finding { + subject: e.uid.clone(), + rule: Rule::InconsistentOptionCount, + severity: Severity::Low, + message: format!( + "has {} options where the rest of the bank has {modal}; an odd \ + count is itself a cue on a printed form", + e.item.options.len() + ), + }); + } + } + } + out +} + +/// Flags pairs of items with nearly the same stem. +/// +/// Two near-identical items are usually an accident of copying, and putting both +/// on one form double-counts a single piece of knowledge. +/// +/// # Arguments +/// +/// * `catalog` - the loaded course. +/// * `t` - thresholds. +/// +/// # Returns +/// +/// One finding per duplicate pair. +fn lint_duplicate_stems(catalog: &Catalog, t: &Thresholds) -> Vec { + let live: Vec<&Entry> = catalog + .entries + .iter() + .filter(|e| e.item.status != Status::Retired) + .collect(); + + let toks: Vec> = live.iter().map(|e| tokens(&e.item.stem)).collect(); + let mut out = Vec::new(); + for i in 0..live.len() { + for j in (i + 1)..live.len() { + if toks[i].len() < 4 || toks[j].len() < 4 { + continue; + } + let sim = jaccard(&toks[i], &toks[j]); + if sim >= t.stem_similarity { + out.push(Finding { + subject: live[i].uid.clone(), + rule: Rule::DuplicateStem, + severity: Severity::Medium, + message: format!( + "stem is {:.0}% the same as `{}`; putting both on one form \ + double-counts one idea", + sim * 100.0, + live[j].uid + ), + }); + } + } + } + out +} + +/// A word that appears in the stem and in the key but in no distractor. +/// +/// Short and common words are ignored, since "the" appearing in both proves +/// nothing. +/// +/// # Arguments +/// +/// * `it` - the item. +/// +/// # Returns +/// +/// The cue word, when one exists. +fn repeated_cue_word(it: &Item) -> Option { + let stem_words = tokens(&it.stem); + let key_words: BTreeSet = it + .options + .iter() + .filter(|o| o.correct) + .flat_map(|o| tokens(&o.text)) + .collect(); + let distractor_words: BTreeSet = it + .options + .iter() + .filter(|o| !o.correct) + .flat_map(|o| tokens(&o.text)) + .collect(); + + for w in stem_words.intersection(&key_words) { + // A technical term is long; a function word is not. + if w.chars().count() >= 7 && !distractor_words.contains(w) { + return Some(w.clone()); + } + } + None +} + +/// Whether a negation word is emphasized in the stem. +/// +/// # Arguments +/// +/// * `stem` - the stem as written. +/// * `neg` - the lowercase negation word. +/// +/// # Returns +/// +/// `true` when the word appears uppercased or inside emphasis markup. +fn negation_is_emphasized(stem: &str, neg: &str) -> bool { + let upper = neg.to_uppercase(); + if stem.contains(&upper) { + return true; + } + // `*not*`, `**not**`, `_not_` all count as emphasis. + for wrap in ['*', '_'] { + let pattern = format!("{wrap}{neg}{wrap}"); + if stem.to_lowercase().contains(&pattern) { + return true; + } + } + false +} + +/// Whether a lowercase haystack contains a word as a whole word. +/// +/// # Arguments +/// +/// * `haystack` - lowercase text. +/// * `needle` - the lowercase word. +/// +/// # Returns +/// +/// `true` on a whole-word match. +fn contains_word(haystack: &str, needle: &str) -> bool { + haystack + .split(|c: char| !c.is_alphanumeric() && c != '\'') + .any(|w| w == needle) +} + +/// Lowercased alphabetic tokens of length 3 or more. +/// +/// # Arguments +/// +/// * `text` - the text to tokenize. +/// +/// # Returns +/// +/// The token set. +fn tokens(text: &str) -> BTreeSet { + text.split(|c: char| !c.is_alphanumeric()) + .map(|w| w.to_lowercase()) + .filter(|w| w.chars().count() >= 3) + .collect() +} + +/// Jaccard similarity of two token sets. +/// +/// # Arguments +/// +/// * `a` - the first set. +/// * `b` - the second set. +/// +/// # Returns +/// +/// Similarity in `[0, 1]`; 0 when both are empty. +fn jaccard(a: &BTreeSet, b: &BTreeSet) -> f64 { + if a.is_empty() && b.is_empty() { + return 0.0; + } + let inter = a.intersection(b).count() as f64; + let union = a.union(b).count() as f64; + if union == 0.0 { 0.0 } else { inter / union } +} + +/// The Flesch-Kincaid grade level of a passage. +/// +/// This is a crude instrument, and it is used only to notice prose that is +/// *unusually* dense, never to prescribe a target. +/// +/// # Arguments +/// +/// * `text` - the passage. +/// +/// # Returns +/// +/// The estimated US grade level, 0 for empty input. +pub fn flesch_kincaid_grade(text: &str) -> f64 { + let words: Vec<&str> = text.split_whitespace().collect(); + if words.is_empty() { + return 0.0; + } + let sentences = text + .chars() + .filter(|c| matches!(c, '.' | '?' | '!' | ';')) + .count() + .max(1); + let syllables: usize = words.iter().map(|w| syllables(w)).sum(); + let w = words.len() as f64; + let s = sentences as f64; + let y = syllables as f64; + 0.39 * (w / s) + 11.8 * (y / w) - 15.59 +} + +/// Estimates the syllable count of a word. +/// +/// Counts vowel groups, drops a silent trailing `e`, and never returns 0. +/// +/// # Arguments +/// +/// * `word` - the word. +/// +/// # Returns +/// +/// The estimated syllable count, at least 1. +fn syllables(word: &str) -> usize { + let w: String = word + .chars() + .filter(|c| c.is_alphabetic()) + .map(|c| c.to_ascii_lowercase()) + .collect(); + if w.is_empty() { + return 0; + } + let is_vowel = |c: char| matches!(c, 'a' | 'e' | 'i' | 'o' | 'u' | 'y'); + let mut count = 0; + let mut prev_vowel = false; + for c in w.chars() { + let v = is_vowel(c); + if v && !prev_vowel { + count += 1; + } + prev_vowel = v; + } + if w.ends_with('e') && count > 1 { + count -= 1; + } + count.max(1) +} + +/// Groups findings by rule for a summary table. +/// +/// # Arguments +/// +/// * `findings` - the findings to summarize. +/// +/// # Returns +/// +/// A map from rule to count. +pub fn summarize(findings: &[Finding]) -> BTreeMap { + let mut out: BTreeMap = BTreeMap::new(); + for f in findings { + *out.entry(f.rule).or_insert(0) += 1; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_rule_appears_in_all_exactly_once() { + let mut seen = Rule::ALL.to_vec(); + let before = seen.len(); + seen.sort_by_key(|r| r.code()); + seen.dedup_by_key(|r| r.code()); + assert_eq!(seen.len(), before, "Rule::ALL has a duplicate"); + } + + #[test] + fn every_rule_has_a_unique_nonempty_code_and_description() { + let mut codes: Vec<&str> = Rule::ALL.iter().map(|r| r.code()).collect(); + codes.sort_unstable(); + let count = codes.len(); + codes.dedup(); + assert_eq!(codes.len(), count, "rule codes must be unique"); + for rule in Rule::ALL { + assert!(!rule.code().is_empty()); + assert!(!rule.description().is_empty(), "{:?}", rule); + // Codes are what users type into --ignore, so they must be shell-safe. + assert!( + rule.code() + .chars() + .all(|c| c.is_ascii_lowercase() || c == '-'), + "{} is not a plain lowercase code", + rule.code() + ); + } + } + use crate::catalog::Entry; + use std::path::PathBuf; + + fn course() -> CourseFile { + serde_yaml_ng::from_str("course: { code: X, title: Y, term: Z }").unwrap() + } + + fn entry(yaml: &str) -> Entry { + let item: Item = serde_yaml_ng::from_str(yaml).expect("item parses"); + Entry { + uid: format!("b::{}", item.id), + bank: "b".into(), + path: PathBuf::from("b.yaml"), + index: 0, + item, + } + } + + fn codes(yaml: &str) -> Vec<&'static str> { + let e = entry(yaml); + let mut c: Vec<&'static str> = lint_item(&e, &course(), &Thresholds::default()) + .iter() + .map(|f| f.rule.code()) + .collect(); + c.sort_unstable(); + c.dedup(); + c + } + + #[test] + fn clean_item_passes() { + let c = codes( + r#" +id: q-a-001 +status: draft +level: 2 +stem: Which mechanism best explains the sigmoidal binding curve? +options: + - { id: A, text: Ligand binding shifts the tetramer to a higher-affinity state, correct: true } + - { id: B, text: Each subunit binds with the same fixed affinity throughout } + - { id: C, text: "Ligand is consumed as it binds, depleting the available pool" } + - { id: D, text: The heme iron changes oxidation state upon binding } +"#, + ); + assert!(c.is_empty(), "expected no findings, got {c:?}"); + } + + #[test] + fn detects_the_length_cue() { + let c = codes( + r#" +id: q-a-001 +status: draft +level: 1 +stem: What is the hydrophobic effect? +options: + - { id: A, text: "The tendency of nonpolar groups to associate in water, driven mainly by the resulting increase in the entropy of the surrounding solvent shell", correct: true } + - { id: B, text: Van der Waals attraction } + - { id: C, text: Hydrogen bonding } + - { id: D, text: Heat release } +"#, + ); + assert!(c.contains(&"cue-key-longest"), "{c:?}"); + } + + #[test] + fn detects_unemphasized_negation_and_accepts_emphasis() { + let bare = codes( + r#" +id: q-a-001 +status: draft +level: 1 +stem: Which of these is not a product of the reaction? +options: + - { id: A, text: aaaa, correct: true } + - { id: B, text: bbbb } + - { id: C, text: cccc } +"#, + ); + assert!(bare.contains(&"clarity-negation"), "{bare:?}"); + + let emphasized = codes( + r#" +id: q-a-001 +status: draft +level: 1 +stem: Which of these is NOT a product of the reaction? +options: + - { id: A, text: aaaa, correct: true } + - { id: B, text: bbbb } + - { id: C, text: cccc } +"#, + ); + assert!(!emphasized.contains(&"clarity-negation"), "{emphasized:?}"); + } + + #[test] + fn detects_all_of_the_above_and_overlap() { + let c = codes( + r#" +id: q-a-001 +status: draft +level: 1 +stem: Which applies? +options: + - { id: A, text: The enzyme is inhibited } + - { id: B, text: The enzyme is inhibited competitively } + - { id: C, text: All of the above, correct: true } +"#, + ); + assert!(c.contains(&"cue-all-of-the-above"), "{c:?}"); + assert!(c.contains(&"cue-overlap"), "{c:?}"); + } + + #[test] + fn detects_the_word_repeat_cue() { + let c = codes( + r#" +id: q-a-001 +status: draft +level: 2 +stem: Which process explains cooperativity in this system? +options: + - { id: A, text: Conformational coupling produces cooperativity, correct: true } + - { id: B, text: Independent binding at each site } + - { id: C, text: Substrate depletion during the assay } +"#, + ); + assert!(c.contains(&"cue-word-repeat"), "{c:?}"); + } + + #[test] + fn stem_without_a_task_is_flagged_but_completions_are_not() { + assert!( + codes( + r#" +id: q-a-001 +status: draft +level: 1 +stem: The hydrophobic effect. +options: + - { id: A, text: aaaa, correct: true } + - { id: B, text: bbbb } +"# + ) + .contains(&"clarity-no-task") + ); + + assert!( + !codes( + r#" +id: q-a-001 +status: draft +level: 1 +stem: "The initial velocity will most nearly:" +options: + - { id: A, text: aaaa, correct: true } + - { id: B, text: bbbb } +"# + ) + .contains(&"clarity-no-task") + ); + } + + #[test] + fn completeness_rules_only_apply_once_ready() { + let draft = codes( + r#" +id: q-a-001 +status: draft +level: 1 +stem: What is x? +options: + - { id: A, text: right, correct: true } + - { id: B, text: wrong } +"#, + ); + assert!(!draft.contains(&"complete-distractor-design")); + + let ready = codes( + r#" +id: q-a-001 +status: in_review +level: 1 +stem: What is x? +options: + - { id: A, text: right, correct: true } + - { id: B, text: wrong } +"#, + ); + assert!(ready.contains(&"complete-distractor-design"), "{ready:?}"); + } + + #[test] + fn stale_calibration_is_high_severity() { + let e = entry( + r#" +id: q-a-001 +status: approved +level: 1 +stem: What is x? +options: + - { id: A, text: right, correct: true } + - { id: B, text: wrong } +calibration: + fingerprint: "0000000000000000" + p_value: 0.8 +"#, + ); + let f = lint_item(&e, &course(), &Thresholds::default()); + let stale = f + .iter() + .find(|f| f.rule == Rule::StaleCalibration) + .expect("stale calibration detected"); + assert_eq!(stale.severity, Severity::High); + } + + #[test] + fn missed_predictions_are_reported() { + let e = entry( + r#" +id: q-a-001 +status: approved +level: 3 +stem: What is x? +options: + - { id: A, text: right, correct: true } + - { id: B, text: wrong } +design: + expected_difficulty: 0.85 + expected_discrimination: high +calibration: + p_value: 0.30 + point_biserial: 0.05 +"#, + ); + let mut c: Vec<&str> = lint_item(&e, &course(), &Thresholds::default()) + .iter() + .map(|f| f.rule.code()) + .collect(); + c.sort_unstable(); + assert!(c.contains(&"evidence-difficulty"), "{c:?}"); + assert!(c.contains(&"evidence-discrimination"), "{c:?}"); + } + + #[test] + fn reading_grade_is_sane() { + let simple = flesch_kincaid_grade("The cat sat on the mat. It was a warm day."); + let dense = flesch_kincaid_grade( + "Notwithstanding the aforementioned considerations regarding \ + thermodynamic favorability, the conformational equilibrium demonstrates \ + substantial entropic contributions attributable to solvent reorganization.", + ); + assert!(simple < 6.0, "simple prose scored {simple}"); + assert!(dense > 16.0, "dense prose scored {dense}"); + } + + #[test] + fn syllable_estimates_are_close_enough() { + assert_eq!(syllables("cat"), 1); + assert_eq!(syllables("water"), 2); + assert_eq!(syllables("enzyme"), 2); + assert_eq!(syllables("a"), 1); + assert_eq!(syllables(""), 0); + } + + #[test] + fn jaccard_and_tokens_behave() { + assert_eq!(jaccard(&tokens("the enzyme"), &tokens("the enzyme")), 1.0); + assert_eq!(jaccard(&tokens("alpha"), &tokens("beta")), 0.0); + // Short words are dropped, so "a b c" has no tokens. + assert!(tokens("a b c").is_empty()); + } + + #[test] + fn every_rule_has_a_unique_code() { + let mut codes: Vec<&str> = Rule::all().iter().map(|r| r.code()).collect(); + let n = codes.len(); + codes.sort_unstable(); + codes.dedup(); + assert_eq!(codes.len(), n, "rule codes must be unique"); + } +} diff --git a/src/authoring/select.rs b/src/authoring/select.rs new file mode 100644 index 0000000..09fbff9 --- /dev/null +++ b/src/authoring/select.rs @@ -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, + /// Bonus item ids. + pub bonus: Vec, + /// 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, +} + +impl Selection { + /// Every selected id, scored then bonus. + pub fn all(&self) -> Vec { + 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 { + 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 = Vec::new(); + let mut per_bank: BTreeMap = 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 = 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 = 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, + per_bank: &mut BTreeMap, + blueprint: &Blueprint, +) -> Result<(Vec, Vec)> { + 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, 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 { + 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
= (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 { + // 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 = record.items.iter().filter(|p| !p.bonus).cloned().collect(); + let bonus: Vec = 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 { + let mut order: Vec = (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 { + 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 { + 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")); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..1870ee5 --- /dev/null +++ b/src/cli.rs @@ -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, + /// Skip these rule codes. + #[arg(long, value_delimiter = ',')] + pub(crate) ignore: Vec, + /// 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, + }, + /// 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, + /// 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, + /// 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, + /// 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, + /// Bonus items per level, same syntax. + #[arg(long, value_delimiter = ',')] + pub(crate) bonus: Vec, + /// Minimum items per objective, e.g. --require lo-kinetics=2. + #[arg(long, value_delimiter = ',')] + pub(crate) require: Vec, + /// Restrict the draw to these lectures. + #[arg(long, value_delimiter = ',')] + pub(crate) lectures: Vec, + /// Restrict the draw to these topics. + #[arg(long, value_delimiter = ',')] + pub(crate) topics: Vec, + /// Restrict the draw to these banks. + #[arg(long, value_delimiter = ',')] + pub(crate) banks: Vec, + /// At most this many items from any one bank. + #[arg(long)] + pub(crate) max_per_bank: Option, + /// 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, + }, + /// 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/-.zip. + #[arg(long)] + out: Option, + /// 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, + /// Which documents to write; defaults to all three. + #[arg(long, value_name = "VARIANT")] + variant: Vec, + /// 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, + /// 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/.md. + #[arg(long)] + out: Option, + }, +} + +#[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, + }, + /// 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, + /// Destination directory; defaults to templates/. + #[arg(long)] + out: Option, + /// 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, + /// Destination path; defaults to templates/typst.yaml. + #[arg(long)] + out: Option, + /// 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, + /// Which form, if forms were used. + #[arg(long)] + pub(crate) form: Option, + /// 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, + /// Storage format. + #[arg(long, value_enum)] + pub(crate) format: Option, + /// 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//. + #[arg(long)] + out: Option, + /// 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/-cohort.md. + #[arg(long)] + out: Option, + }, +} + +#[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()); + } +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..e4a7f7d --- /dev/null +++ b/src/commands.rs @@ -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 { + 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), + } +} diff --git a/src/commands/analysis.rs b/src/commands/analysis.rs new file mode 100644 index 0000000..6c3b7c7 --- /dev/null +++ b/src/commands/analysis.rs @@ -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 { + 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 { + 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::>() + .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 { + 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 { + 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 { + 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) +} diff --git a/src/commands/banks.rs b/src/commands/banks.rs new file mode 100644 index 0000000..ee0b84c --- /dev/null +++ b/src/commands/banks.rs @@ -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 { + 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 { + 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 { + 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::()?, + 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 { + let catalog = load(cli)?; + let history = History::load(&catalog.layout.assessments())?; + + match sub { + UsageCommand::History { item } => { + let uids: Vec = 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 = 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) + } + } +} diff --git a/src/commands/export.rs b/src/commands/export.rs new file mode 100644 index 0000000..ef645b3 --- /dev/null +++ b/src/commands/export.rs @@ -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 { + 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 = 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::>() + .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 .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> { + 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 { + 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 to \ + see what it currently produces", + path.display() + ))); + } + yaml::write_text(&path, typst::CONFIG_TEMPLATE)?; + println!("wrote {}", path.display()); + Ok(Outcome::Ok) + } + } +} diff --git a/src/commands/project.rs b/src/commands/project.rs new file mode 100644 index 0000000..f7deb8b --- /dev/null +++ b/src/commands/project.rs @@ -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 { + 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 { + 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 { + 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 { + 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 , 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 { + 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/")); + } +} diff --git a/src/data.rs b/src/data.rs new file mode 100644 index 0000000..db5b7f2 --- /dev/null +++ b/src/data.rs @@ -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; diff --git a/src/data/canvas.rs b/src/data/canvas.rs new file mode 100644 index 0000000..16f9982 --- /dev/null +++ b/src/data/canvas.rs @@ -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 `: ` 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, + /// 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, +} + +/// 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(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("→", "->") + .replace("↔", "<->") + .replace("−", "-"); + + 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 { + 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 = 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 = 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::() { + 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 \ + `: `. 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> = BTreeMap::new(); + let mut item_meta: BTreeMap, Vec)> = 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| -> Option { + 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::().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::().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, +) { + 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> = 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 = Vec::new(); + let mut unmatched: Vec = 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 { + 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 = "Km increases"; + let returned = "

Km increases

"; + assert_eq!(normalize(sent), normalize(returned)); + assert_eq!(normalize(returned), "k m increases"); + assert_eq!(normalize("K 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("bold 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(); + } +} diff --git a/src/data/gradescope.rs b/src/data/gradescope.rs new file mode 100644 index 0000000..050739c --- /dev/null +++ b/src/data/gradescope.rs @@ -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, , +//! 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, + /// 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, + /// Points this column awards, from the `Point Values` row. + pub value: Option, +} + +/// One student's row in a question file. +#[derive(Debug, Clone)] +pub struct StudentRow { + /// The institutional student id. + pub sid: Option, + /// First and last name joined. + pub name: Option, + /// The email. + pub email: Option, + /// Section or lab. + pub section: Option, + /// Points awarded, authoritative. + pub score: f64, + /// The submission timestamp, verbatim. + pub submission_time: Option, + /// Indices of rubric columns marked `true`. + pub marks: Vec, +} + +/// 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, + /// The maximum points from the `Point Values` row. + pub max_points: f64, + /// The scoring method, when stated. + pub scoring_method: Option, + /// The student rows. + pub rows: Vec, +} + +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 { + let top = self.top_value(); + if top <= 0.0 { + return Vec::new(); + } + let mut out: BTreeSet = 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)> { + 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 { + 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, + /// The form, when forms were used. + pub form: Option, +} + +/// 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, +} + +/// 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, Option) { + 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 { + let number = path + .file_stem() + .and_then(|s| s.to_str()) + .and_then(|s| s.parse::().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 = 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 = 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 = 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| -> Option { + 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 = rec + .iter() + .skip(1) + .filter(|c| !c.trim().is_empty()) + .filter_map(|c| c.trim().parse::().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::().ok()) { + Some(s) => s, + None => { + if cell(sid_col).is_none() && cell(email_col).is_none() { + continue; + } + 0.0 + } + }; + + let marks: Vec = (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 { + let mut paths: Vec = 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::().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 = BTreeMap::new(); + + for q in questions { + let keyed: BTreeSet = 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 = 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 = counts.values().copied().collect(); + if sizes.len() > 1 { + let mut odd: Vec = 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(); + } +} diff --git a/src/data/responses.rs b/src/data/responses.rs new file mode 100644 index 0000000..12f4a0e --- /dev/null +++ b/src/data/responses.rs @@ -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, + /// Which form the student took, when forms were used. + pub form: Option, + + /// The identifier analysis groups by. A pseudonym when pseudonymizing. + pub student_key: String, + /// The institutional student id, absent when pseudonymized. + pub sid: Option, + /// The student's name, absent when pseudonymized. + pub name: Option, + /// The student's email, absent when pseudonymized. + pub email: Option, + /// Section or lab, kept because it is the grouping most likely to reveal a + /// delivery problem rather than a learning one. + pub section: Option, + + /// 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, + /// The item version as administered. + pub item_version: Option, + + /// Option letters the student chose. + pub selected: Vec, + /// Option letters the student eliminated, for elimination-scored items. + pub eliminated: Vec, + /// 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, + /// 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, + + /// The item's level, denormalized so analysis need not carry the catalog. + pub level: Option, + /// The item's learning objectives, denormalized for per-objective mastery. + pub learning_objectives: Vec, + /// The item's topics, denormalized. + pub topics: Vec, + /// 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 { + 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, + /// Non-fatal problems: unmatched columns, students with no responses, + /// question numbers absent from the assessment record. + pub warnings: Vec, +} + +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 { + 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 { + let set: BTreeSet = 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 { + let set: BTreeSet = 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 = 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 = 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 = + 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 { + let mut warnings = Vec::new(); + let mut unmatched: BTreeSet = 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 = 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 { + 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, + /// Question numbers, one per column. + pub items: Vec, + /// Credit fractions; `None` for a missing response. + pub credit: Vec>>, + /// Dichotomous codes; `None` for a missing response. + pub coded: Vec>>, +} + +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 { + 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 { + 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> { + 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 { + 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 { + 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); + } +} diff --git a/src/data/store.rs b/src/data/store.rs new file mode 100644 index 0000000..f843193 --- /dev/null +++ b/src/data/store.rs @@ -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 { + 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) -> Result { + 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 { + 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> { + let mut written = Vec::new(); + for admin in set.administrations() { + let rows: Vec = 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 { + // 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> { + if !self.dir.exists() { + return Ok(Vec::new()); + } + let mut out: Vec = 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 { + 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 { + 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 { + 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 { + 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::() { + 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 { + 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 { + 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 = 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> { + 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> { + let mut out: std::collections::BTreeMap> = 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()); + } +} diff --git a/src/data/store_parquet.rs b/src/data/store_parquet.rs new file mode 100644 index 0000000..32143fc --- /dev/null +++ b/src/data/store_parquet.rs @@ -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 { + let s = |f: fn(&FlatResponse) -> &str| -> ArrayRef { + Arc::new(StringArray::from(rows.iter().map(f).collect::>())) + }; + let f64c = |f: fn(&FlatResponse) -> f64| -> ArrayRef { + Arc::new(Float64Array::from(rows.iter().map(f).collect::>())) + }; + let u32c = |f: fn(&FlatResponse) -> u32| -> ArrayRef { + Arc::new(UInt32Array::from(rows.iter().map(f).collect::>())) + }; + let boolc = |f: fn(&FlatResponse) -> bool| -> ArrayRef { + Arc::new(BooleanArray::from( + rows.iter().map(f).collect::>(), + )) + }; + + let columns: Vec = 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> { + 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> { + let strings = |name: &str| -> Result<&StringArray> { + batch + .column_by_name(name) + .and_then(|c| c.as_any().downcast_ref::()) + .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::()) + .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::()) + .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::()) + .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(); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..b8023c8 --- /dev/null +++ b/src/error.rs @@ -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 = std::result::Result; + +/// 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), + + /// 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, + }, + + /// 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, 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::>() + .join("\n") +} + +/// Renders the optional context of an unresolved reference. +fn context_suffix(context: &Option) -> String { + match context { + Some(c) => format!(" (referenced by {c})"), + None => String::new(), + } +} diff --git a/src/export.rs b/src/export.rs new file mode 100644 index 0000000..2a02566 --- /dev/null +++ b/src/export.rs @@ -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; diff --git a/src/export/qti.rs b/src/export/qti.rs new file mode 100644 index 0000000..99fa46c --- /dev/null +++ b/src/export/qti.rs @@ -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 `` as +//! character data, which means it is escaped once on the way in and unescaped +//! once by Canvas. So `→` is written as `&rarr;`. Skipping that step +//! produces XML that parses and renders as literal `→` 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, + children: Vec, +} + +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) -> 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) -> 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 { + 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!( + "\n{}", + self.render(0) + ) + } +} + +/// Escapes text content. +fn escape_text(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +/// Escapes an attribute value. +fn escape_attr(s: &str) -> String { + escape_text(s).replace('"', """) +} + +// --------------------------------------------------------------------------- +// 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 { + 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 `` 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 = 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 = 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!( + "
{}
", + 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!("
{}
", markup::to_html(text)))), + ), + ); + } + } + } + + node +} + +/// A `` 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 `` 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("

a → b

"); + let xml = n.render(0); + // The HTML is character data, so its markup is escaped. + assert!(xml.contains("<p>a &rarr; b</p>"), "{xml}"); + assert!(!xml.contains("

")); + } + + #[test] + fn attributes_are_escaped() { + let xml = Node::new("item") + .attr("title", "a \"quoted\" & ") + .render(0); + assert!(xml.contains(""quoted"")); + assert!(xml.contains("&")); + assert!(!xml.contains("")); + } + + #[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::().unwrap() >= 1000); + } + + #[test] + fn empty_element_renders_self_closing() { + assert_eq!( + Node::new("file").attr("href", "q.xml").render(0), + "\n" + ); + } + + #[test] + fn document_has_a_declaration() { + let doc = Node::new("root").document(); + assert!(doc.starts_with("\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"); + } +} diff --git a/src/export/report.rs b/src/export/report.rs new file mode 100644 index 0000000..f084047 --- /dev/null +++ b/src/export/report.rs @@ -0,0 +1,1084 @@ +// SPDX-License-Identifier: Prosperity-3.0.0 +// Copyright Scientific Computing Studio +// Source: https://git.scient.ing/education/coursebank + +//! Writing reports for students and for yourself. +//! +//! Two audiences, two documents, and the difference between them is not tone but +//! content. +//! +//! The **student report** answers "what should I do next?". It gives a score, a +//! coarse position in the class, per-objective standing, and — the part that +//! actually helps — for each missed question, the misconception that the specific +//! distractor they chose was written to detect, plus where in the course to go +//! back to. It never prints a correct answer, never names another student, and +//! never reports a rank. Those omissions are deliberate: a report that reveals +//! keys cannot be sent out before a makeup exam, and a report that gives a rank +//! invites the student to read it as a verdict rather than as instructions. +//! +//! The **instructor report** answers "what should I fix?". Item statistics, the +//! revision queue, distractor tables, reliability, blueprint coverage, and the +//! class-level objectives that nobody met. That last section is the one that +//! should change your teaching rather than any individual student's studying. +//! +//! Output is Markdown. It is readable as-is in a terminal or a text editor, it +//! diffs cleanly, and [`to_html`] converts it for emailing or posting. The HTML +//! converter handles exactly the Markdown this module emits — headings, tables, +//! lists, bold, italic, code, blockquotes, rules — and nothing more; it is not a +//! general Markdown implementation and does not pretend to be. + +use std::collections::BTreeMap; + +use crate::assessment::AssessmentFile; +use crate::catalog::Catalog; +use crate::classical::Analysis; +use crate::course::CourseFile; +use crate::date::Date; +use crate::irt::Fit; +use crate::students::{Cohort, Mastery, StudentSummary}; +use crate::taxonomy::Level; + +/// What to include in a student report. +#[derive(Debug, Clone)] +pub struct StudentOptions { + /// Whether to include the per-objective table. + pub objectives: bool, + /// Whether to include the per-level comparison to the class. + pub levels: bool, + /// Whether to include per-question guidance on missed items. + pub missed: bool, + /// Whether to show the class mean and the student's band. Turn this off for a + /// course where any comparison is unwelcome. + pub comparison: bool, + /// Whether to include the IRT ability estimate. Off by default: it is not + /// meaningful to most students and invites misreading. + pub ability: bool, + /// A closing note appended verbatim, e.g. office-hours information. + pub closing: Option, +} + +impl Default for StudentOptions { + fn default() -> StudentOptions { + StudentOptions { + objectives: true, + levels: true, + missed: true, + comparison: true, + ability: false, + closing: None, + } + } +} + +/// Writes one student's report. +/// +/// # Arguments +/// +/// * `summary` - the student's summary. +/// * `cohort` - the class context, for means. +/// * `course` - the course, for titles. +/// * `record` - the assessment record, for the title and date. +/// * `opts` - what to include. +/// +/// # Returns +/// +/// A Markdown document. +pub fn student( + summary: &StudentSummary, + cohort: &Cohort, + course: &CourseFile, + record: &AssessmentFile, + opts: &StudentOptions, +) -> String { + let mut out = String::new(); + + out.push_str(&format!( + "# {} — {}\n\n", + record.assessment.title, course.course.code + )); + out.push_str(&format!("**{}**\n\n", summary.display_name())); + + // ---------------------------------------------------------------- score + out.push_str(&format!( + "You scored **{:.1} of {:.1} points ({:.0}%)**", + summary.points, summary.points_possible, summary.percent + )); + if summary.bonus_points > 0.0 { + out.push_str(&format!( + ", plus {:.1} bonus point{}", + summary.bonus_points, + if (summary.bonus_points - 1.0).abs() < 1e-9 { + "" + } else { + "s" + } + )); + } + out.push_str(&format!( + ", answering {} of {} questions correctly.\n\n", + summary.correct, summary.n_items + )); + + if opts.comparison { + out.push_str(&format!( + "The class averaged {:.0}%. Your score is in the {}.\n\n", + cohort.mean_percent, summary.band + )); + } + + if opts.ability { + if let (Some(theta), Some(se)) = (summary.theta, summary.theta_se) { + out.push_str(&format!( + "Adjusting for how difficult each question turned out to be, your estimated \ + standing is {} (θ = {theta:+.2}, ± {:.2}). The margin is wide because a single \ + exam is a small amount of evidence.\n\n", + crate::irt::Ability { + student_key: summary.student_key.clone(), + theta, + se, + n_items: summary.n_items, + } + .band(), + se * 1.96 + )); + } + } + + // ----------------------------------------------------------- objectives + if opts.objectives && !summary.objectives.is_empty() { + out.push_str("## What this exam says about each learning objective\n\n"); + out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n"); + for o in &summary.objectives { + let you = format!("{:.0}%", o.rate * 100.0); + out.push_str(&format!( + "| {} | {} | {} | {:.0}% | {} |\n", + o.status.symbol(), + escape_pipes(&o.text), + you, + o.cohort_rate * 100.0, + o.n_items + )); + } + out.push('\n'); + out.push_str("✓ meeting · ~ developing · ✗ not yet · ? too few questions to tell\n\n"); + + // The "too few questions" cases are an honest caveat about the exam, and + // saying so protects the student from over-reading a single data point. + let thin: Vec<&str> = summary + .objectives + .iter() + .filter(|o| o.status == Mastery::NotEnoughEvidence) + .map(|o| o.text.as_str()) + .collect(); + if !thin.is_empty() { + out.push_str(&format!( + "This exam had too few questions on {} to say anything reliable about {}. Treat \ + those rows as information about the exam, not about you.\n\n", + list(&thin), + if thin.len() == 1 { "it" } else { "them" } + )); + } + } + + // --------------------------------------------------------------- levels + if opts.levels && summary.levels.len() > 1 { + out.push_str("## Kinds of thinking\n\n"); + out.push_str( + "Questions on this exam asked for different kinds of thinking. Comparing your rate \ + across them often shows more than the total score does.\n\n", + ); + out.push_str("| Kind of question | You | Class | |\n|:--|--:|--:|:--|\n"); + for l in &summary.levels { + out.push_str(&format!( + "| {} — {} | {:.0}% | {:.0}% | {} |\n", + l.level.name(), + l.level.blurb(), + l.rate * 100.0, + l.cohort_rate * 100.0, + bar(l.rate) + )); + } + out.push('\n'); + + // The interesting pattern: fine on recall, falling apart on application. + let recall: Vec = summary + .levels + .iter() + .filter(|l| l.level.code() <= 2) + .map(|l| l.rate) + .collect(); + let applied: Vec = summary + .levels + .iter() + .filter(|l| l.level.code() >= 3) + .map(|l| l.rate) + .collect(); + if !recall.is_empty() && !applied.is_empty() { + let drop = average(&recall) - average(&applied); + if drop > 0.2 { + out.push_str( + "You are recalling the material but losing ground when you have to use it. \ + That usually means more practice working problems rather than more rereading \ + — rereading feels productive and mostly rebuilds recognition.\n\n", + ); + } else if drop < -0.2 { + out.push_str( + "You reason well with the material when it is in front of you, but specific \ + facts and terms are costing you points. That is the more tractable of the two \ + problems: targeted memorization of the terms below will help.\n\n", + ); + } + } + } + + // ---------------------------------------------------------- what to do + if !summary.focus.is_empty() { + out.push_str("## Where to put your time\n\n"); + out.push_str("In this order:\n\n"); + for (i, id) in summary.focus.iter().take(4).enumerate() { + let text = course.objective_text(id); + out.push_str(&format!("{}. {}\n", i + 1, text)); + } + out.push('\n'); + + // Distinguish "you missed this" from "the class missed this", because the + // second is going to be retaught and does not need solo review. + let class_gaps: Vec<&str> = cohort + .class_gaps + .iter() + .filter(|(id, _)| summary.focus.contains(id)) + .map(|(id, _)| id.as_str()) + .collect(); + if !class_gaps.is_empty() { + let texts: Vec = class_gaps + .iter() + .map(|id| course.objective_text(id)) + .collect(); + let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect(); + out.push_str(&format!( + "Most of the class also struggled with {}, so expect it to come back in class. \ + Prioritize the other items above for solo review.\n\n", + list(&refs) + )); + } + } + + if !summary.strengths.is_empty() { + let texts: Vec = summary + .strengths + .iter() + .take(4) + .map(|id| course.objective_text(id)) + .collect(); + let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect(); + out.push_str(&format!("You have clearly got {}.\n\n", list(&refs))); + } + + // --------------------------------------------------------- missed items + if opts.missed && !summary.missed.is_empty() { + out.push_str("## Question by question\n\n"); + out.push_str( + "For each question you missed, here is what the answer you chose usually indicates, \ + and where to go back to. Correct answers are not listed here.\n\n", + ); + for m in &summary.missed { + let partial = if m.credit > 0.0 { + format!(" (partial credit: {:.0}%)", m.credit * 100.0) + } else { + String::new() + }; + out.push_str(&format!("**Question {}**{partial}\n\n", m.number)); + if let Some(text) = &m.feedback { + out.push_str(&format!("{text}\n\n")); + } else if let Some(misconception) = &m.misconception { + out.push_str(&format!( + "The option you chose is the one students pick when {misconception}\n\n" + )); + } + if !m.study.is_empty() { + out.push_str(&format!("Review: {}\n\n", m.study.join("; "))); + } + } + } + + if let Some(closing) = &opts.closing { + out.push_str("---\n\n"); + out.push_str(closing); + out.push_str("\n\n"); + } + + out.push_str(&format!( + "---\n\n*Generated {} for {}. Percentages on individual objectives come from a handful of \ + questions each and carry real uncertainty; read them as directions, not measurements.*\n", + Date::today(), + summary.display_name() + )); + + out +} + +/// Writes the instructor's report on one administration. +/// +/// # Arguments +/// +/// * `analysis` - classical item analysis. +/// * `cohort` - per-student summaries and class rates. +/// * `catalog` - the loaded course. +/// * `record` - the assessment record. +/// * `fit` - an optional IRT fit. +/// +/// # Returns +/// +/// A Markdown document. +pub fn cohort( + analysis: &Analysis, + cohort: &Cohort, + catalog: &Catalog, + record: &AssessmentFile, + fit: Option<&Fit>, +) -> String { + let mut out = String::new(); + let course = &catalog.course; + + out.push_str(&format!( + "# {} — item analysis\n\n{} · {} · {}\n\n", + record.assessment.title, + course.course.code, + record + .assessment + .term + .clone() + .unwrap_or_else(|| course.course.term.clone()), + record + .assessment + .date + .map(|d| d.to_string()) + .unwrap_or_else(|| "date not recorded".into()) + )); + + // ------------------------------------------------------------- summary + let r = &analysis.reliability; + out.push_str("## Summary\n\n"); + out.push_str(&format!( + "- {} examinees, {} scored items\n- Mean score {:.1} of {} ({:.0}%), SD {:.2}\n- \ + Mean p-value {:.2}, mean point-biserial {}\n", + r.n_students, + r.n_items, + r.mean, + r.n_items, + if r.n_items > 0 { + 100.0 * r.mean / r.n_items as f64 + } else { + 0.0 + }, + r.sd, + r.mean_p, + r.mean_point_biserial + .map(|v| format!("{v:+.2}")) + .unwrap_or_else(|| "n/a".into()) + )); + out.push('\n'); + out.push_str(&r.interpretation()); + out.push_str("\n\n"); + + if let Some(f) = fit { + let peak = f.peak_information(); + out.push_str(&format!( + "The IRT fit ({} model, {} iterations{}) measures most precisely around θ = {peak:+.1}", + match f.items.first().map(|i| i.model) { + Some(m) => m.as_str(), + None => "?", + }, + f.iterations, + if f.converged { + "" + } else { + ", did not converge" + } + )); + match f.standard_error(peak) { + Some(se) => out.push_str(&format!( + ", where the standard error is {se:.2} logits.\n\n" + )), + None => out.push_str(".\n\n"), + } + } + + for w in &analysis.warnings { + out.push_str(&format!("> {w}\n\n")); + } + + // ------------------------------------------------------- revise queue + let queue = analysis.revise_queue(); + out.push_str("## What to revise\n\n"); + if queue.is_empty() { + out.push_str( + "Nothing was flagged. Unusual, and worth a skeptical glance at whether the \ + key and the record actually matched the exam.\n\n", + ); + } else { + let blocking = queue.iter().filter(|i| i.needs_revision()).count(); + out.push_str(&format!( + "{} item(s) flagged; {blocking} need attention before being used again.\n\n", + queue.len() + )); + for item in queue { + let label = item + .item_ref + .clone() + .unwrap_or_else(|| format!("question {}", item.number)); + out.push_str(&format!( + "### Q{} — {}{}\n\n", + item.number, + label, + if item.needs_revision() { " ⚠" } else { "" } + )); + out.push_str(&format!( + "p = {:.2} · r = {} · flags: {}\n\n", + item.p_value, + item.point_biserial + .map(|v| format!("{v:+.2}")) + .unwrap_or_else(|| "n/a".into()), + item.flags + .iter() + .map(|f| f.as_str()) + .collect::>() + .join(", ") + )); + for note in &item.notes { + out.push_str(&format!("- {note}\n")); + } + out.push('\n'); + + // The distractor table is where a poorly worded item shows itself. + if item.options.len() > 1 { + out.push_str( + "| Option | Chose | r | Upper | Lower | |\n|:--|--:|--:|--:|--:|:--|\n", + ); + for o in item.options.values() { + out.push_str(&format!( + "| {} | {:.0}% | {} | {} | {} | {} |\n", + o.letter, + o.rate * 100.0, + o.point_biserial + .map(|v| format!("{v:+.2}")) + .unwrap_or_else(|| "n/a".into()), + o.upper_rate + .map(|v| format!("{:.0}%", v * 100.0)) + .unwrap_or_else(|| "-".into()), + o.lower_rate + .map(|v| format!("{:.0}%", v * 100.0)) + .unwrap_or_else(|| "-".into()), + if o.is_key { "**key**" } else { "" } + )); + } + out.push('\n'); + } + } + } + + // ---------------------------------------------------------- item table + out.push_str("## Every item\n\n"); + out.push_str( + "| Q | Item | Lv | p | r | D | Blank | Flags |\n|--:|:--|--:|--:|--:|--:|--:|:--|\n", + ); + for item in &analysis.items { + let level = record + .placement(item.number) + .and_then(|p| p.level) + .map(|l| l.code().to_string()) + .unwrap_or_else(|| "-".into()); + out.push_str(&format!( + "| {} | {} | {} | {:.2} | {} | {} | {:.0}% | {} |\n", + item.number, + item.item_ref.clone().unwrap_or_default(), + level, + 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::>() + .join(" ") + )); + } + out.push('\n'); + + if let Some(f) = fit { + out.push_str("## IRT parameters\n\n"); + out.push_str("| Q | a | b | SE(a) | SE(b) | n | Notes |\n|--:|--:|--:|--:|--:|--:|:--|\n"); + for item in &f.items { + out.push_str(&format!( + "| {} | {:.2} | {:+.2} | {} | {} | {} | {} |\n", + 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.n, + item.notes.join(" ") + )); + } + out.push('\n'); + for w in &f.warnings { + out.push_str(&format!("> {w}\n\n")); + } + } + + // -------------------------------------------------------- class gaps + out.push_str("## Objectives the class did not meet\n\n"); + if cohort.class_gaps.is_empty() { + out.push_str("Every assessed objective cleared the mastery threshold.\n\n"); + } else { + out.push_str(&format!( + "Below the {:.0}% threshold. These are the candidates for reteaching rather than for \ + individual review.\n\n", + course.policy.mastery_threshold * 100.0 + )); + out.push_str("| Objective | Class rate |\n|:--|--:|\n"); + for (id, rate) in &cohort.class_gaps { + out.push_str(&format!( + "| {} | {:.0}% |\n", + escape_pipes(&course.objective_text(id)), + rate * 100.0 + )); + } + out.push('\n'); + } + + // ------------------------------------------------------ level coverage + out.push_str("## Coverage and class performance by level\n\n"); + let counts = record.level_counts(); + out.push_str("| Level | Items | Class rate |\n|:--|--:|--:|\n"); + for level in Level::ALL { + let n = counts.get(&level).copied().unwrap_or(0); + if n == 0 { + continue; + } + out.push_str(&format!( + "| {} {} | {} | {:.0}% |\n", + level.code(), + level.name(), + n, + cohort.level_rates.get(&level).copied().unwrap_or(0.0) * 100.0 + )); + } + out.push('\n'); + + if let Some(bp) = &record.blueprint { + let drift = crate::select::check_blueprint(record); + if !drift.is_empty() { + out.push_str("Blueprint drift:\n\n"); + for d in &drift { + out.push_str(&format!("- {d}\n")); + } + out.push('\n'); + } + let _ = bp; + } + + // -------------------------------------------------------- archetypes + if !cohort.archetypes.is_empty() { + out.push_str("## Patterns across students\n\n"); + out.push_str( + "Descriptive grouping by performance profile across levels, not a diagnosis. Useful \ + for deciding whether a review session should target one gap or several.\n\n", + ); + for a in &cohort.archetypes { + let means: Vec = a + .level_means + .iter() + .map(|(l, v)| format!("L{}: {:.0}%", l.code(), v * 100.0)) + .collect(); + out.push_str(&format!( + "- **{}** — {} student(s); {}\n", + a.label, + a.members.len(), + means.join(", ") + )); + } + out.push('\n'); + } + + out.push_str(&format!("---\n\n*Generated {}.*\n", Date::today())); + out +} + +/// Writes a one-line-per-student roster of scores. +/// +/// # Arguments +/// +/// * `cohort` - the class. +/// +/// # Returns +/// +/// A Markdown table. +pub fn roster(cohort: &Cohort) -> String { + let mut out = + String::from("| Student | Points | % | Correct | Focus |\n|:--|--:|--:|--:|:--|\n"); + let mut sorted: Vec<&StudentSummary> = cohort.students.iter().collect(); + sorted.sort_by(|a, b| { + b.percent + .partial_cmp(&a.percent) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.student_key.cmp(&b.student_key)) + }); + for s in sorted { + out.push_str(&format!( + "| {} | {:.1} | {:.0}% | {}/{} | {} |\n", + s.display_name(), + s.points, + s.percent, + s.correct, + s.n_items, + s.focus + .iter() + .take(2) + .cloned() + .collect::>() + .join(", ") + )); + } + out +} + +/// A small unicode bar for a rate in `0.0..=1.0`. +/// +/// # Arguments +/// +/// * `rate` - the value. +/// +/// # Returns +/// +/// A ten-cell bar. +fn bar(rate: f64) -> String { + let filled = (rate.clamp(0.0, 1.0) * 10.0).round() as usize; + format!("{}{}", "█".repeat(filled), "░".repeat(10 - filled)) +} + +/// The mean of a slice, zero when empty. +fn average(v: &[f64]) -> f64 { + if v.is_empty() { + 0.0 + } else { + v.iter().sum::() / v.len() as f64 + } +} + +/// Joins items into an English list. +/// +/// # Arguments +/// +/// * `items` - the items. +/// +/// # Returns +/// +/// `"a"`, `"a and b"`, or `"a, b, and c"`. +fn list(items: &[&str]) -> String { + match items.len() { + 0 => String::new(), + 1 => items[0].to_string(), + 2 => format!("{} and {}", items[0], items[1]), + _ => { + let head = items[..items.len() - 1].join(", "); + format!("{head}, and {}", items[items.len() - 1]) + } + } +} + +/// Escapes pipes so objective text cannot break a Markdown table. +fn escape_pipes(s: &str) -> String { + s.replace('|', "\\|") +} + +/// Converts the Markdown this module emits into a standalone HTML document. +/// +/// Handles headings, paragraphs, unordered and ordered lists, tables, +/// blockquotes, horizontal rules, and inline bold, italic, and code. This is not a +/// general Markdown implementation: it covers the constructs the generators above +/// produce, and unrecognized syntax passes through as escaped text rather than +/// being silently mangled. +/// +/// # Arguments +/// +/// * `markdown` - the document. +/// * `title` - the HTML title. +/// +/// # Returns +/// +/// A complete HTML document with embedded styles, so it can be emailed or opened +/// with no other files. +pub fn to_html(markdown: &str, title: &str) -> String { + let mut body = String::new(); + let mut lines = markdown.lines().peekable(); + let mut list_kind: Option<&str> = None; + + // Closes an open list, if any. + fn close_list(body: &mut String, list_kind: &mut Option<&str>) { + if let Some(tag) = list_kind.take() { + body.push_str(&format!("\n")); + } + } + + while let Some(line) = lines.next() { + let trimmed = line.trim(); + + if trimmed.is_empty() { + close_list(&mut body, &mut list_kind); + continue; + } + + if trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-') { + close_list(&mut body, &mut list_kind); + body.push_str("


\n"); + continue; + } + + if let Some(rest) = trimmed.strip_prefix("> ") { + close_list(&mut body, &mut list_kind); + body.push_str(&format!("
{}
\n", inline(rest))); + continue; + } + + // Headings. + let hashes = trimmed.chars().take_while(|c| *c == '#').count(); + if hashes > 0 && hashes <= 6 && trimmed.chars().nth(hashes) == Some(' ') { + close_list(&mut body, &mut list_kind); + let text = trimmed[hashes + 1..].trim(); + body.push_str(&format!("{}\n", inline(text))); + continue; + } + + // Tables: a header row followed by an alignment row. + if trimmed.starts_with('|') { + let is_separator = |s: &str| { + s.trim().starts_with('|') + && s.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ' | '\t')) + && s.contains('-') + }; + if lines.peek().map(|n| is_separator(n)).unwrap_or(false) { + close_list(&mut body, &mut list_kind); + lines.next(); + body.push_str("\n"); + for cell in split_row(trimmed) { + body.push_str(&format!("", inline(&cell))); + } + body.push_str("\n\n"); + while let Some(next) = lines.peek() { + if !next.trim().starts_with('|') { + break; + } + // `peek` just succeeded, so `next` cannot be `None`; a + // `let else` says that without a panic in the path. + let Some(row) = lines.next() else { break }; + body.push_str(""); + for cell in split_row(row.trim()) { + body.push_str(&format!("", inline(&cell))); + } + body.push_str("\n"); + } + body.push_str("\n
{}
{}
\n"); + continue; + } + } + + // Lists. + if let Some(rest) = trimmed.strip_prefix("- ") { + if list_kind != Some("ul") { + close_list(&mut body, &mut list_kind); + body.push_str("
    \n"); + list_kind = Some("ul"); + } + body.push_str(&format!("
  • {}
  • \n", inline(rest))); + continue; + } + if let Some((prefix, rest)) = trimmed.split_once(". ") { + if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) { + if list_kind != Some("ol") { + close_list(&mut body, &mut list_kind); + body.push_str("
      \n"); + list_kind = Some("ol"); + } + body.push_str(&format!("
    1. {}
    2. \n", inline(rest))); + continue; + } + } + + close_list(&mut body, &mut list_kind); + body.push_str(&format!("

      {}

      \n", inline(trimmed))); + } + close_list(&mut body, &mut list_kind); + + format!( + "\n\n\n\n\ + \n\ + {}\n\n\n\n{}\n\n", + crate::markup::escape_html(title), + STYLE, + body + ) +} + +/// Splits a Markdown table row into cells. +fn split_row(row: &str) -> Vec { + let inner = row.trim().trim_start_matches('|').trim_end_matches('|'); + inner + .split('|') + .map(|c| c.trim().replace("\\|", "|")) + .collect() +} + +/// Converts inline Markdown to HTML. +/// +/// Escapes first, then applies emphasis, so text containing angle brackets cannot +/// become markup. +fn inline(s: &str) -> String { + let escaped = crate::markup::escape_html(s); + let mut out = escaped; + out = wrap(&out, "**", "", ""); + out = wrap(&out, "`", "", ""); + out = wrap(&out, "*", "", ""); + out +} + +/// Replaces paired delimiters, leaving unpaired ones literal. +fn wrap(s: &str, delim: &str, open: &str, close: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + loop { + let Some(i) = rest.find(delim) else { + out.push_str(rest); + return out; + }; + let after = &rest[i + delim.len()..]; + let Some(j) = after.find(delim) else { + out.push_str(rest); + return out; + }; + if j == 0 { + out.push_str(&rest[..i + delim.len()]); + rest = after; + continue; + } + out.push_str(&rest[..i]); + out.push_str(open); + out.push_str(&after[..j]); + out.push_str(close); + rest = &after[j + delim.len()..]; + } +} + +/// Embedded stylesheet for HTML reports. +const STYLE: &str = "\ +:root { color-scheme: light dark; } +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + max-width: 46rem; margin: 2.5rem auto; padding: 0 1.25rem; line-height: 1.55; + color: #1a1a1a; background: #fff; } +h1 { font-size: 1.6rem; margin-bottom: 0.2rem; } +h2 { font-size: 1.15rem; margin-top: 2rem; border-bottom: 1px solid #e5e5e5; + padding-bottom: 0.3rem; } +h3 { font-size: 1rem; margin-top: 1.5rem; } +table { border-collapse: collapse; width: 100%; margin: 1rem 0; font-size: 0.92rem; } +th, td { border-bottom: 1px solid #e5e5e5; padding: 0.4rem 0.6rem; text-align: left; } +th { background: #fafafa; font-weight: 600; } +td:nth-child(n+3), th:nth-child(n+3) { text-align: right; } +code { background: #f5f5f5; padding: 0.1rem 0.3rem; border-radius: 3px; + font-size: 0.9em; } +blockquote { border-left: 3px solid #d0d0d0; margin: 1rem 0; padding: 0.3rem 0 0.3rem 1rem; + color: #555; font-size: 0.95rem; } +hr { border: none; border-top: 1px solid #e5e5e5; margin: 2rem 0; } +em { color: #555; } +ul, ol { padding-left: 1.4rem; } +@media (prefers-color-scheme: dark) { + body { color: #e8e8e8; background: #1a1a1a; } + th { background: #262626; } + th, td { border-bottom-color: #333; } + code { background: #2a2a2a; } + h2 { border-bottom-color: #333; } + blockquote { border-left-color: #444; color: #aaa; } +} +"; + +/// Writes every student's report to a directory. +/// +/// # Arguments +/// +/// * `dir` - the destination directory. +/// * `cohort` - the class. +/// * `course` - the course. +/// * `record` - the assessment record. +/// * `opts` - report options. +/// * `html` - whether to write HTML alongside Markdown. +/// +/// # Returns +/// +/// The paths written. +/// +/// # Errors +/// +/// Returns [`crate::error::Error::Io`] on a write failure. +pub fn write_all_students( + dir: &std::path::Path, + cohort: &Cohort, + course: &CourseFile, + record: &AssessmentFile, + opts: &StudentOptions, + html: bool, +) -> crate::error::Result> { + std::fs::create_dir_all(dir).map_err(|e| crate::error::Error::io(dir, e))?; + let mut written = Vec::new(); + for s in &cohort.students { + let stem = crate::store::sanitize(&s.student_key); + let markdown = student(s, cohort, course, record, opts); + let md_path = dir.join(format!("{stem}.md")); + crate::yaml::write_text(&md_path, &markdown)?; + written.push(md_path); + if html { + let title = format!("{} — {}", record.assessment.title, s.display_name()); + let html_path = dir.join(format!("{stem}.html")); + crate::yaml::write_text(&html_path, &to_html(&markdown, &title))?; + written.push(html_path); + } + } + Ok(written) +} + +/// Per-objective class rates as a compact table, for pasting into a syllabus +/// review or a curriculum committee document. +/// +/// # Arguments +/// +/// * `cohort` - the class. +/// * `course` - the course, for objective text. +/// +/// # Returns +/// +/// A Markdown table. +pub fn objective_summary(cohort: &Cohort, course: &CourseFile) -> String { + let mut out = String::from("| Objective | Class rate |\n|:--|--:|\n"); + let mut rows: Vec<(&String, &f64)> = cohort.objective_rates.iter().collect(); + rows.sort_by(|a, b| { + a.1.partial_cmp(b.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.0.cmp(b.0)) + }); + for (id, rate) in rows { + out.push_str(&format!( + "| {} | {:.0}% |\n", + escape_pipes(&course.objective_text(id)), + rate * 100.0 + )); + } + out +} + +/// Counts flags across an analysis, for a headline figure. +/// +/// # Arguments +/// +/// * `analysis` - the analysis. +/// +/// # Returns +/// +/// How many items carry each flag. +pub fn flag_counts(analysis: &Analysis) -> BTreeMap<&'static str, usize> { + let mut out = BTreeMap::new(); + for item in &analysis.items { + for flag in &item.flags { + *out.entry(flag.as_str()).or_insert(0) += 1; + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bars_are_ten_cells_wide() { + assert_eq!(bar(0.0).chars().count(), 10); + assert_eq!(bar(1.0).chars().count(), 10); + assert_eq!(bar(0.5).chars().count(), 10); + assert!(bar(1.0).starts_with('█')); + assert!(bar(0.0).starts_with('░')); + // Out-of-range input must not panic or overflow. + assert_eq!(bar(2.0).chars().count(), 10); + assert_eq!(bar(-1.0).chars().count(), 10); + } + + #[test] + fn english_lists_read_correctly() { + assert_eq!(list(&[]), ""); + assert_eq!(list(&["a"]), "a"); + assert_eq!(list(&["a", "b"]), "a and b"); + assert_eq!(list(&["a", "b", "c"]), "a, b, and c"); + } + + #[test] + fn pipes_in_objective_text_do_not_break_tables() { + assert_eq!(escape_pipes("a | b"), "a \\| b"); + } + + #[test] + fn html_conversion_handles_headings_and_paragraphs() { + let html = to_html("# Title\n\nSome text.\n", "T"); + assert!(html.contains("

      Title

      ")); + assert!(html.contains("

      Some text.

      ")); + assert!(html.starts_with("")); + assert!(html.contains("T")); + } + + #[test] + fn html_conversion_builds_tables() { + let md = "| A | B |\n|:--|--:|\n| 1 | 2 |\n"; + let html = to_html(md, "T"); + assert!(html.contains("")); + assert!(html.contains("")); + assert!(html.contains("")); + assert!(html.contains("")); + } + + #[test] + fn html_conversion_handles_both_list_kinds() { + let html = to_html("- one\n- two\n\n1. first\n2. second\n", "T"); + assert!(html.contains("
        ")); + assert!(html.contains("
      • one
      • ")); + assert!(html.contains("
          ")); + assert!(html.contains("
        1. first
        2. ")); + // Lists must be closed, not left dangling. + assert_eq!(html.matches("
            ").count(), html.matches("
          ").count()); + assert_eq!(html.matches("
            ").count(), html.matches("
          ").count()); + } + + #[test] + fn html_conversion_escapes_before_emphasis() { + let html = to_html("**bold** and \n", "T"); + assert!(html.contains("bold")); + assert!(!html.contains(""); + assert!(!out.contains("
      A2