refactor: splitting models
This commit is contained in:
+2
-2
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "coursebank"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
edition = "2024"
|
||||
rust-version = "1.85"
|
||||
description = "Author, assemble, administer, and analyze leveled course assessments from YAML item banks"
|
||||
authors = ["Alex Maldonado <alexm@scient.ing>"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -226,7 +226,8 @@ Assembling a form programmatically:
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use coursebank::assessment::{Blueprint, History};
|
||||
use coursebank::assessment::Blueprint;
|
||||
use coursebank::history::History;
|
||||
use coursebank::date::Date;
|
||||
use coursebank::{select, Catalog, Level};
|
||||
|
||||
|
||||
@@ -622,7 +622,7 @@ pub fn apply(plan: &Plan) -> Result<Vec<PathBuf>> {
|
||||
"{} — the bank changed since the plan was built; re-run calibration",
|
||||
path.display()
|
||||
)),
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -794,12 +794,14 @@ mod tests {
|
||||
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")));
|
||||
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]
|
||||
|
||||
+16
-15
@@ -197,11 +197,7 @@ impl ItemFit {
|
||||
// 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
|
||||
}
|
||||
if den <= 0.0 { 0.0 } else { num / den }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,7 +420,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
|
||||
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 in 0..j_count {
|
||||
for (j, b_j) in b.iter_mut().enumerate() {
|
||||
let column = matrix.column(j);
|
||||
let answered: Vec<u8> = column.into_iter().flatten().collect();
|
||||
if answered.is_empty() {
|
||||
@@ -434,7 +430,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
|
||||
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();
|
||||
*b_j = -(clamped / (1.0 - clamped)).ln();
|
||||
}
|
||||
|
||||
let mut iterations = 0usize;
|
||||
@@ -1102,8 +1098,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rasch_fixes_discrimination_at_one() {
|
||||
let mut opts = Options::default();
|
||||
opts.model = IrtModel::Rasch;
|
||||
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()));
|
||||
@@ -1111,14 +1109,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn three_pl_reports_a_lower_asymptote_and_warns() {
|
||||
let mut opts = Options::default();
|
||||
opts.model = IrtModel::ThreePl;
|
||||
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.items
|
||||
.iter()
|
||||
.all(|i| i.c.unwrap() >= 0.0 && i.c.unwrap() <= 0.4)
|
||||
);
|
||||
assert!(f.warnings.iter().any(|w| w.contains("1000")));
|
||||
}
|
||||
|
||||
|
||||
@@ -883,7 +883,7 @@ pub fn cluster(students: &[StudentSummary], k: usize) -> Vec<Archetype> {
|
||||
level_means,
|
||||
});
|
||||
}
|
||||
out.sort_by(|a, b| b.members.len().cmp(&a.members.len()));
|
||||
out.sort_by_key(|b| std::cmp::Reverse(b.members.len()));
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//! generic derivation would emit as bare strings. The crate's own validation
|
||||
//! remains authoritative; the schema is for the editor.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::course::SCHEMA_VERSION;
|
||||
use crate::error::Result;
|
||||
@@ -998,9 +998,11 @@ mod tests {
|
||||
"# 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"));
|
||||
assert!(
|
||||
Kind::Course
|
||||
.modeline("schema/")
|
||||
.ends_with("schema/course.schema.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+13
-13
@@ -1058,11 +1058,7 @@ fn jaccard(a: &BTreeSet<String>, b: &BTreeSet<String>) -> f64 {
|
||||
}
|
||||
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
|
||||
}
|
||||
if union == 0.0 { 0.0 } else { inter / union }
|
||||
}
|
||||
|
||||
/// The Flesch-Kincaid grade level of a passage.
|
||||
@@ -1313,8 +1309,9 @@ options:
|
||||
|
||||
#[test]
|
||||
fn stem_without_a_task_is_flagged_but_completions_are_not() {
|
||||
assert!(codes(
|
||||
r#"
|
||||
assert!(
|
||||
codes(
|
||||
r#"
|
||||
id: q-a-001
|
||||
status: draft
|
||||
level: 1
|
||||
@@ -1323,11 +1320,13 @@ options:
|
||||
- { id: A, text: aaaa, correct: true }
|
||||
- { id: B, text: bbbb }
|
||||
"#
|
||||
)
|
||||
.contains(&"clarity-no-task"));
|
||||
)
|
||||
.contains(&"clarity-no-task")
|
||||
);
|
||||
|
||||
assert!(!codes(
|
||||
r#"
|
||||
assert!(
|
||||
!codes(
|
||||
r#"
|
||||
id: q-a-001
|
||||
status: draft
|
||||
level: 1
|
||||
@@ -1336,8 +1335,9 @@ options:
|
||||
- { id: A, text: aaaa, correct: true }
|
||||
- { id: B, text: bbbb }
|
||||
"#
|
||||
)
|
||||
.contains(&"clarity-no-task"));
|
||||
)
|
||||
.contains(&"clarity-no-task")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+10
-12
@@ -23,13 +23,12 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::assessment::{
|
||||
Assessment, AssessmentFile, Blueprint, Form, History, Kind, Placement, Platform,
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -444,14 +443,14 @@ pub fn to_record(
|
||||
) -> Result<AssessmentFile> {
|
||||
let default_points = catalog.course.policy.points_per_item;
|
||||
let mut items = Vec::new();
|
||||
let mut number = 1u32;
|
||||
|
||||
for (uid, is_bonus) in selection
|
||||
.scored
|
||||
.iter()
|
||||
.map(|u| (u, false))
|
||||
.chain(selection.bonus.iter().map(|u| (u, true)))
|
||||
{
|
||||
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,
|
||||
@@ -466,7 +465,6 @@ pub fn to_record(
|
||||
credit_overrides: BTreeMap::new(),
|
||||
dropped: false,
|
||||
});
|
||||
number += 1;
|
||||
}
|
||||
|
||||
let form_list: Vec<Form> = (0..forms)
|
||||
@@ -553,7 +551,7 @@ pub fn layout(record: &AssessmentFile, form: &Form) -> Vec<Placement> {
|
||||
rng.shuffle(&mut scored);
|
||||
}
|
||||
|
||||
scored.into_iter().chain(bonus.into_iter()).collect()
|
||||
scored.into_iter().chain(bonus).collect()
|
||||
}
|
||||
|
||||
/// The option order for one item on one form.
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
use coursebank::calibrate;
|
||||
use coursebank::canvas;
|
||||
use coursebank::classical::{self, Thresholds};
|
||||
use coursebank::course::Layout;
|
||||
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;
|
||||
@@ -114,8 +114,8 @@ pub(crate) fn analyze(cli: &Cli, sub: &AnalyzeCommand) -> Result<Outcome> {
|
||||
}
|
||||
println!("{}\n", analysis.reliability.interpretation());
|
||||
println!(
|
||||
"{:>3} {:>5} {:>6} {:>6} {:>6} {}",
|
||||
"Q", "p", "r", "D", "blank", "FLAGS"
|
||||
"{:>3} {:>5} {:>6} {:>6} {:>6} FLAGS",
|
||||
"Q", "p", "r", "D", "blank"
|
||||
);
|
||||
for item in &analysis.items {
|
||||
println!(
|
||||
@@ -184,8 +184,8 @@ pub(crate) fn analyze(cli: &Cli, sub: &AnalyzeCommand) -> Result<Outcome> {
|
||||
);
|
||||
|
||||
println!(
|
||||
"{:>3} {:>6} {:>7} {:>7} {:>7} {}",
|
||||
"Q", "a", "b", "SE(a)", "SE(b)", "NOTES"
|
||||
"{:>3} {:>6} {:>7} {:>7} {:>7} NOTES",
|
||||
"Q", "a", "b", "SE(a)", "SE(b)"
|
||||
);
|
||||
for item in &fit.items {
|
||||
println!(
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use coursebank::assessment::{AssessmentFile, Blueprint, History};
|
||||
use coursebank::assessment::{AssessmentFile, Blueprint};
|
||||
use coursebank::bank::BankFile;
|
||||
use coursebank::course::Layout;
|
||||
use coursebank::date::Date;
|
||||
use coursebank::error::{Error, Result};
|
||||
use coursebank::history::History;
|
||||
use coursebank::layout::Layout;
|
||||
use coursebank::select;
|
||||
use coursebank::yaml;
|
||||
|
||||
@@ -102,7 +103,7 @@ pub(crate) fn assessment(cli: &Cli, sub: &AssessmentCommand) -> Result<Outcome>
|
||||
let catalog = load(cli)?;
|
||||
let record = load_record(&catalog, id)?;
|
||||
print_record(&catalog, &record);
|
||||
let issues = record.validate(Some(&catalog));
|
||||
let issues = catalog.validate_record(&record);
|
||||
if !issues.is_empty() {
|
||||
println!("\n{} problem(s):", issues.len());
|
||||
for issue in &issues {
|
||||
@@ -213,7 +214,7 @@ pub(crate) fn usage(cli: &Cli, sub: &UsageCommand) -> Result<Outcome> {
|
||||
Some(id) => vec![catalog.resolve(id)?],
|
||||
None => catalog.entries.iter().map(|e| e.uid.clone()).collect(),
|
||||
};
|
||||
println!("{:<34} {:>5} {:<12} {}", "ITEM", "USES", "LAST", "WHERE");
|
||||
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() {
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
//! [`pick_variants`], which resolves the `--variant` flags into a canonical list.
|
||||
|
||||
use coursebank::assessment::Form;
|
||||
use coursebank::course::Layout;
|
||||
use coursebank::error::{Error, Result};
|
||||
use coursebank::layout::Layout;
|
||||
use coursebank::qti;
|
||||
use coursebank::typst;
|
||||
use coursebank::yaml;
|
||||
|
||||
@@ -9,9 +9,10 @@ use std::collections::BTreeMap;
|
||||
|
||||
use coursebank::assessment::AssessmentFile;
|
||||
use coursebank::bank::BankFile;
|
||||
use coursebank::course::{CourseFile, Layout, COURSE_FILE};
|
||||
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;
|
||||
@@ -98,7 +99,7 @@ pub(crate) fn validate(cli: &Cli) -> Result<Outcome> {
|
||||
let records = AssessmentFile::load_all(&catalog.layout.assessments())?;
|
||||
let mut all = issues;
|
||||
for record in &records {
|
||||
for issue in record.validate(Some(&catalog)) {
|
||||
for issue in catalog.validate_record(record) {
|
||||
all.push(format!("{}: {issue}", record.assessment.id));
|
||||
}
|
||||
}
|
||||
@@ -128,7 +129,7 @@ pub(crate) fn validate(cli: &Cli) -> Result<Outcome> {
|
||||
/// [`Outcome::Findings`] when anything fires, unless `--no-fail` was given.
|
||||
pub(crate) fn lint(cli: &Cli, args: &LintArgs) -> Result<Outcome> {
|
||||
if args.list_rules {
|
||||
println!("{:<32} {:<8} {}", "CODE", "SEVERITY", "WHAT IT CATCHES");
|
||||
println!("{:<32} {:<8} WHAT IT CATCHES", "CODE", "SEVERITY");
|
||||
for rule in Rule::ALL {
|
||||
println!(
|
||||
"{:<32} {:<8} {}",
|
||||
@@ -230,8 +231,8 @@ pub(crate) fn catalog(cli: &Cli, args: &CatalogArgs) -> Result<Outcome> {
|
||||
let coverage = catalog.coverage();
|
||||
println!("\nObjective coverage:");
|
||||
println!(
|
||||
" {:<40} {:>6} {:>6} {}",
|
||||
"OBJECTIVE", "ITEMS", "READY", "MAX LEVEL"
|
||||
" {:<40} {:>6} {:>6} MAX LEVEL",
|
||||
"OBJECTIVE", "ITEMS", "READY"
|
||||
);
|
||||
for row in &coverage.rows {
|
||||
println!(
|
||||
|
||||
+9
-10
@@ -26,7 +26,7 @@ use crate::assessment::AssessmentFile;
|
||||
use crate::catalog::Catalog;
|
||||
use crate::date::Date;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::responses::{administration_id, Response, ResponseSet};
|
||||
use crate::responses::{Response, ResponseSet, administration_id};
|
||||
|
||||
/// What the ingest needs that the export does not carry.
|
||||
pub type Context = crate::gradescope::Context;
|
||||
@@ -120,11 +120,9 @@ pub fn normalize(s: &str) -> String {
|
||||
if c.is_alphanumeric() {
|
||||
out.push(c);
|
||||
last_space = false;
|
||||
} else if c.is_whitespace() || c == '-' || c == '_' {
|
||||
if !last_space {
|
||||
out.push(' ');
|
||||
last_space = true;
|
||||
}
|
||||
} else if (c.is_whitespace() || c == '-' || c == '_') && !last_space {
|
||||
out.push(' ');
|
||||
last_space = true;
|
||||
}
|
||||
// Everything else — punctuation, entity leftovers — is dropped.
|
||||
}
|
||||
@@ -579,10 +577,11 @@ mod tests {
|
||||
|
||||
// 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!(
|
||||
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);
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::date::Date;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::responses::{administration_id, Response, ResponseSet};
|
||||
use crate::responses::{Response, ResponseSet, administration_id};
|
||||
use crate::taxonomy::Flag;
|
||||
|
||||
/// What a rubric column represents.
|
||||
@@ -358,7 +358,7 @@ pub fn parse_question(path: &Path) -> Result<Question> {
|
||||
"{} 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());
|
||||
|
||||
@@ -17,8 +17,8 @@ use std::sync::Arc;
|
||||
|
||||
use arrow_array::{ArrayRef, BooleanArray, Float64Array, RecordBatch, StringArray, UInt32Array};
|
||||
use arrow_schema::{DataType, Field, Schema};
|
||||
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
|
||||
use parquet::arrow::ArrowWriter;
|
||||
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
|
||||
use parquet::basic::Compression;
|
||||
use parquet::file::properties::WriterProperties;
|
||||
|
||||
|
||||
@@ -145,11 +145,7 @@ pub fn student(
|
||||
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 = if o.status == Mastery::NotEnoughEvidence {
|
||||
format!("{:.0}%", o.rate * 100.0)
|
||||
} else {
|
||||
format!("{:.0}%", o.rate * 100.0)
|
||||
};
|
||||
let you = format!("{:.0}%", o.rate * 100.0);
|
||||
out.push_str(&format!(
|
||||
"| {} | {} | {} | {:.0}% | {} |\n",
|
||||
o.status.symbol(),
|
||||
|
||||
+3
-3
@@ -53,16 +53,16 @@ pub mod value;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use config::{
|
||||
ConfigFile, ContentMode, Fields, LetterStyle, Overrides, RenderConfig, Reveal, StimulusMode,
|
||||
Variant, CONFIG_FILE, CONFIG_TEMPLATE,
|
||||
CONFIG_FILE, CONFIG_TEMPLATE, ConfigFile, ContentMode, Fields, LetterStyle, Overrides,
|
||||
RenderConfig, Reveal, StimulusMode, Variant,
|
||||
};
|
||||
pub use payload::Payload;
|
||||
pub use template::{Origin, Slot, Template};
|
||||
pub use value::Value;
|
||||
|
||||
use crate::Layout;
|
||||
use crate::assessment::{AssessmentFile, Form};
|
||||
use crate::catalog::Catalog;
|
||||
use crate::course::Layout;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// What to render.
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::course::Layout;
|
||||
use crate::Layout;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
use super::config::Variant;
|
||||
@@ -702,9 +702,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ordinary_comments_are_left_alone() {
|
||||
assert!(parse("// nothing to see\n// coursebank\n")
|
||||
.unwrap()
|
||||
.is_inert());
|
||||
assert!(
|
||||
parse("// nothing to see\n// coursebank\n")
|
||||
.unwrap()
|
||||
.is_inert()
|
||||
);
|
||||
// A word that merely starts with `begin` is a slot name, not a keyword.
|
||||
let err = parse("// coursebank:beginning\n").unwrap_err();
|
||||
assert!(err.to_string().contains("unknown slot `beginning`"));
|
||||
|
||||
+3
-2
@@ -14,10 +14,11 @@ use std::path::Path;
|
||||
|
||||
use coursebank::assessment::{AssessmentFile, Form};
|
||||
use coursebank::catalog::Catalog;
|
||||
use coursebank::course::{Layout, COURSE_FILE};
|
||||
use coursebank::course::COURSE_FILE;
|
||||
use coursebank::date::Date;
|
||||
use coursebank::error::{Error, Result};
|
||||
use coursebank::gradescope;
|
||||
use coursebank::layout::Layout;
|
||||
use coursebank::responses::ResponseSet;
|
||||
use coursebank::store::Store;
|
||||
use coursebank::taxonomy::Level;
|
||||
@@ -223,7 +224,7 @@ pub(crate) fn print_record(catalog: &Catalog, record: &AssessmentFile) {
|
||||
);
|
||||
println!(
|
||||
"estimated {:.0} minutes of working time",
|
||||
record.estimated_minutes(catalog)
|
||||
catalog.estimated_minutes(record)
|
||||
);
|
||||
|
||||
println!("\nBy level:");
|
||||
|
||||
+5
-3
@@ -44,7 +44,7 @@
|
||||
//!
|
||||
//! Assessment records are the single source of truth for reuse history. There
|
||||
//! is no separate ledger file, because a ledger duplicates what the records must
|
||||
//! already get right and then drifts from it. [`assessment::History`] derives usage
|
||||
//! already get right and then drifts from it. [`history::History`] derives usage
|
||||
//! by scanning the records.
|
||||
//!
|
||||
//! Fingerprints cover only what a student saw. Retag an item's metadata and its
|
||||
@@ -96,7 +96,7 @@ pub mod util;
|
||||
|
||||
pub use util::{date, hash, markup, rng, yaml, zipfile};
|
||||
|
||||
pub use model::{assessment, bank, catalog, course, item, taxonomy};
|
||||
pub use model::{assessment, bank, catalog, course, history, item, layout, taxonomy};
|
||||
|
||||
pub use authoring::{jsonschema, lint, select};
|
||||
|
||||
@@ -109,9 +109,11 @@ pub use analysis::{calibrate, classical, irt, students};
|
||||
pub use export::{qti, report, typst};
|
||||
|
||||
pub use catalog::Catalog;
|
||||
pub use course::{CourseFile, Layout, SCHEMA_VERSION};
|
||||
pub use course::{CourseFile, SCHEMA_VERSION};
|
||||
pub use error::{Error, Result};
|
||||
pub use history::History;
|
||||
pub use item::Item;
|
||||
pub use layout::Layout;
|
||||
pub use taxonomy::{CognitiveProcess, ErrorType, Flag, Format, Level, Status};
|
||||
|
||||
/// Version of package.
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ use std::process::ExitCode;
|
||||
use clap::Parser;
|
||||
|
||||
use crate::cli::Cli;
|
||||
use crate::commands::{run, Outcome};
|
||||
use crate::commands::{Outcome, run};
|
||||
|
||||
/// Parses the command line, runs the requested command, and maps its result onto
|
||||
/// a process exit code.
|
||||
|
||||
@@ -24,5 +24,7 @@ pub mod assessment;
|
||||
pub mod bank;
|
||||
pub mod catalog;
|
||||
pub mod course;
|
||||
pub mod history;
|
||||
pub mod item;
|
||||
pub mod layout;
|
||||
pub mod taxonomy;
|
||||
|
||||
+5
-206
@@ -24,7 +24,6 @@ use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::catalog::Catalog;
|
||||
use crate::course::SCHEMA_VERSION;
|
||||
use crate::date::Date;
|
||||
use crate::error::Result;
|
||||
@@ -396,7 +395,7 @@ impl AssessmentFile {
|
||||
/// # Returns
|
||||
///
|
||||
/// Every problem found.
|
||||
pub fn validate(&self, catalog: Option<&Catalog>) -> Vec<String> {
|
||||
pub fn validate(&self) -> Vec<String> {
|
||||
let mut issues = Vec::new();
|
||||
|
||||
if self.assessment.id.trim().is_empty() {
|
||||
@@ -458,60 +457,9 @@ impl AssessmentFile {
|
||||
form_ids.push(&form.id);
|
||||
}
|
||||
|
||||
if let Some(cat) = catalog {
|
||||
for p in &self.items {
|
||||
match cat.get(&p.item) {
|
||||
None => {
|
||||
issues.push(format!("question {}: unknown item `{}`", p.number, p.item))
|
||||
}
|
||||
Some(entry) => {
|
||||
if let Some(fp) = &p.fingerprint {
|
||||
if *fp != entry.item.fingerprint() {
|
||||
issues.push(format!(
|
||||
"question {} ({}): the item has been edited since this \
|
||||
assessment; statistics from this administration describe \
|
||||
the older wording",
|
||||
p.number, p.item
|
||||
));
|
||||
}
|
||||
}
|
||||
if !p.key.is_empty() && p.key != entry.item.key_letters() {
|
||||
issues.push(format!(
|
||||
"question {} ({}): the recorded key {:?} differs from the \
|
||||
item's current key {:?}",
|
||||
p.number,
|
||||
p.item,
|
||||
p.key,
|
||||
entry.item.key_letters()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
issues
|
||||
}
|
||||
|
||||
/// Estimated time to complete, in minutes.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `catalog` - the loaded course, for per-item time estimates.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The estimate.
|
||||
pub fn estimated_minutes(&self, catalog: &Catalog) -> f64 {
|
||||
let seconds: f64 = self
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|p| catalog.get(&p.item))
|
||||
.map(|e| e.item.expected_seconds())
|
||||
.sum();
|
||||
seconds / 60.0
|
||||
}
|
||||
|
||||
/// A skeleton record for `coursebank assessment new`.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -566,113 +514,6 @@ pub struct Usage {
|
||||
pub fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
/// The usage history of a course, built by scanning assessment records.
|
||||
///
|
||||
/// There is deliberately no separate ledger file. A ledger duplicates
|
||||
/// information that the records already hold and then drifts away from it; this
|
||||
/// derives the same answers from the one artifact that has to be right anyway.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct History {
|
||||
/// Every recorded appearance, newest last.
|
||||
pub usages: Vec<Usage>,
|
||||
}
|
||||
|
||||
impl History {
|
||||
/// Builds the history from a directory of assessment records.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `dir` - the assessments directory.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The history.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates load errors.
|
||||
pub fn load(dir: &Path) -> Result<History> {
|
||||
let mut usages = Vec::new();
|
||||
for file in AssessmentFile::load_all(dir)? {
|
||||
for p in &file.items {
|
||||
usages.push(Usage {
|
||||
item: p.item.clone(),
|
||||
assessment: file.assessment.id.clone(),
|
||||
title: file.assessment.title.clone(),
|
||||
kind: file.assessment.kind,
|
||||
date: file.assessment.date,
|
||||
number: p.number,
|
||||
fingerprint: p.fingerprint.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
usages.sort_by(|a, b| {
|
||||
a.date
|
||||
.cmp(&b.date)
|
||||
.then(a.assessment.cmp(&b.assessment))
|
||||
.then(a.number.cmp(&b.number))
|
||||
});
|
||||
Ok(History { usages })
|
||||
}
|
||||
|
||||
/// Every appearance of one item, oldest first.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `uid` - the item's global id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Matching usages.
|
||||
pub fn for_item(&self, uid: &str) -> Vec<&Usage> {
|
||||
self.usages.iter().filter(|u| u.item == uid).collect()
|
||||
}
|
||||
|
||||
/// The most recent date an item was used.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `uid` - the item's global id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The date, or `None` when never used or never dated.
|
||||
pub fn last_used(&self, uid: &str) -> Option<Date> {
|
||||
self.for_item(uid).iter().filter_map(|u| u.date).max()
|
||||
}
|
||||
|
||||
/// How many times an item has been used.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `uid` - the item's global id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The count.
|
||||
pub fn use_count(&self, uid: &str) -> usize {
|
||||
self.for_item(uid).len()
|
||||
}
|
||||
|
||||
/// Whether an item is still inside its reuse cooldown.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `uid` - the item's global id.
|
||||
/// * `cooldown_days` - the minimum gap between uses.
|
||||
/// * `as_of` - the date of the assessment being assembled.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` when the item was used too recently to reuse.
|
||||
pub fn in_cooldown(&self, uid: &str, cooldown_days: i64, as_of: Date) -> bool {
|
||||
match self.last_used(uid) {
|
||||
Some(last) => last.days_until(as_of) < cooldown_days,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_version() -> String {
|
||||
SCHEMA_VERSION.to_string()
|
||||
}
|
||||
@@ -716,7 +557,7 @@ items:
|
||||
assert_eq!(a.items.len(), 3);
|
||||
assert_eq!(a.total_points(1.0), 3.0, "bonus is excluded");
|
||||
assert_eq!(a.placement(2).unwrap().item, "b1::q-a-002");
|
||||
assert!(a.validate(None).is_empty(), "{:?}", a.validate(None));
|
||||
assert!(a.validate().is_empty(), "{:?}", a.validate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -737,7 +578,7 @@ items:
|
||||
- { number: 1, item: "b::q-1" }
|
||||
"#,
|
||||
);
|
||||
let issues = a.validate(None);
|
||||
let issues = a.validate();
|
||||
assert!(issues.iter().any(|i| i.contains("used 2 times")));
|
||||
assert!(issues.iter().any(|i| i.contains("appears twice")));
|
||||
}
|
||||
@@ -752,10 +593,7 @@ items:
|
||||
- { number: 3, item: "b::q-2" }
|
||||
"#,
|
||||
);
|
||||
assert!(a
|
||||
.validate(None)
|
||||
.iter()
|
||||
.any(|i| i.contains("not contiguous")));
|
||||
assert!(a.validate().iter().any(|i| i.contains("not contiguous")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -767,46 +605,7 @@ items:
|
||||
- { number: 1, item: "b::q-1", credit_overrides: { B: 1.5 } }
|
||||
"#,
|
||||
);
|
||||
assert!(a
|
||||
.validate(None)
|
||||
.iter()
|
||||
.any(|i| i.contains("must be in [0, 1]")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_tracks_last_use_and_cooldown() {
|
||||
let h = History {
|
||||
usages: vec![
|
||||
Usage {
|
||||
item: "b::q-1".into(),
|
||||
assessment: "exam-1".into(),
|
||||
title: "Exam 1".into(),
|
||||
kind: Kind::Exam,
|
||||
date: Some("2025-09-15".parse().unwrap()),
|
||||
number: 4,
|
||||
fingerprint: None,
|
||||
},
|
||||
Usage {
|
||||
item: "b::q-1".into(),
|
||||
assessment: "exam-3".into(),
|
||||
title: "Exam 3".into(),
|
||||
kind: Kind::Exam,
|
||||
date: Some("2026-02-10".parse().unwrap()),
|
||||
number: 7,
|
||||
fingerprint: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(h.use_count("b::q-1"), 2);
|
||||
assert_eq!(h.use_count("b::q-2"), 0);
|
||||
assert_eq!(h.last_used("b::q-1").unwrap().to_string(), "2026-02-10");
|
||||
|
||||
let exam_date: Date = "2026-04-23".parse().unwrap();
|
||||
// 72 days elapsed, so a 90-day cooldown still blocks it and a 60-day one
|
||||
// does not.
|
||||
assert!(h.in_cooldown("b::q-1", 90, exam_date));
|
||||
assert!(!h.in_cooldown("b::q-1", 60, exam_date));
|
||||
assert!(!h.in_cooldown("b::never-used", 3650, exam_date));
|
||||
assert!(a.validate().iter().any(|i| i.contains("must be in [0, 1]")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+11
-7
@@ -491,7 +491,7 @@ fn validate_item(
|
||||
));
|
||||
}
|
||||
}
|
||||
for (letter, _) in &c.option_stats {
|
||||
for letter in c.option_stats.keys() {
|
||||
if it.option(letter).is_none() {
|
||||
issues.push(format!(
|
||||
"calibration.option_stats has `{letter}`, which is not an option of this item"
|
||||
@@ -662,9 +662,11 @@ mod tests {
|
||||
"#,
|
||||
);
|
||||
let issues = b.validate(None);
|
||||
assert!(issues
|
||||
.iter()
|
||||
.any(|i| i.contains("exactly one keyed option")));
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|i| i.contains("exactly one keyed option"))
|
||||
);
|
||||
assert_eq!(
|
||||
issues
|
||||
.iter()
|
||||
@@ -831,9 +833,11 @@ learning_objectives:
|
||||
"#,
|
||||
);
|
||||
let issues = b.validate(Some(&course));
|
||||
assert!(issues
|
||||
.iter()
|
||||
.any(|i| i.contains("unknown learning objective `lo-unknown`")));
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.any(|i| i.contains("unknown learning objective `lo-unknown`"))
|
||||
);
|
||||
assert!(issues.iter().any(|i| i.contains("unknown lecture `L99`")));
|
||||
assert!(
|
||||
issues.iter().any(|i| i.contains("exceeds the ceiling")),
|
||||
|
||||
+44
-2
@@ -16,10 +16,12 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::assessment::AssessmentFile;
|
||||
use crate::bank::BankFile;
|
||||
use crate::course::{CourseFile, Layout};
|
||||
use crate::course::CourseFile;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::item::Item;
|
||||
use crate::layout::Layout;
|
||||
use crate::taxonomy::{Level, Status};
|
||||
use crate::yaml;
|
||||
|
||||
@@ -249,6 +251,46 @@ impl Catalog {
|
||||
Ok(issues)
|
||||
}
|
||||
|
||||
/// Validates a record's internal invariants, then its references against this
|
||||
/// catalog: unknown items, keys that drifted, and fingerprints showing the
|
||||
/// item was reworded since it was administered.
|
||||
pub fn validate_record(&self, record: &AssessmentFile) -> Vec<String> {
|
||||
let mut issues = record.validate();
|
||||
for p in &record.items {
|
||||
match self.get(&p.item) {
|
||||
None => issues.push(format!("question {}: unknown item `{}`", p.number, p.item)),
|
||||
Some(entry) => {
|
||||
if let Some(fp) = &p.fingerprint {
|
||||
if *fp != entry.item.fingerprint() {
|
||||
issues.push(format!(
|
||||
"question {} ({}): the item has been edited since this assessment; \
|
||||
statistics from this administration describe the older wording",
|
||||
p.number, p.item
|
||||
));
|
||||
}
|
||||
}
|
||||
if !p.key.is_empty() && p.key != entry.item.key_letters() {
|
||||
issues.push(format!(
|
||||
"question {} ({}): the recorded key {:?} differs from the item's current key {:?}",
|
||||
p.number, p.item, p.key, entry.item.key_letters()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
issues
|
||||
}
|
||||
|
||||
/// Estimated working time for a record, in minutes.
|
||||
pub fn estimated_minutes(&self, record: &AssessmentFile) -> f64 {
|
||||
let seconds: f64 = record
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|p| self.get(&p.item))
|
||||
.map(|e| e.item.expected_seconds())
|
||||
.sum();
|
||||
seconds / 60.0
|
||||
}
|
||||
|
||||
/// Counts of assemblable, non-bonus items by level.
|
||||
///
|
||||
/// # Returns
|
||||
@@ -391,7 +433,7 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
|
||||
for (lec_id, _) in &self.course.lectures {
|
||||
for lec_id in self.course.lectures.keys() {
|
||||
if self
|
||||
.by_lecture(lec_id)
|
||||
.iter()
|
||||
|
||||
+3
-94
@@ -12,7 +12,7 @@
|
||||
//! belongs to the course and the administration, never to the item.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -307,7 +307,7 @@ impl CourseFile {
|
||||
issues.push(format!("lecture `{id}`: empty title"));
|
||||
}
|
||||
if let Some(u) = &lec.unit {
|
||||
if !unit_ids.iter().any(|x| *x == u) {
|
||||
if !unit_ids.contains(&u) {
|
||||
issues.push(format!("lecture `{id}`: unknown unit `{u}`"));
|
||||
}
|
||||
}
|
||||
@@ -318,7 +318,7 @@ impl CourseFile {
|
||||
issues.push(format!("objective `{id}`: empty text"));
|
||||
}
|
||||
if let Some(u) = &lo.unit {
|
||||
if !unit_ids.iter().any(|x| *x == u) {
|
||||
if !unit_ids.contains(&u) {
|
||||
issues.push(format!("objective `{id}`: unknown unit `{u}`"));
|
||||
}
|
||||
}
|
||||
@@ -550,97 +550,6 @@ impl CourseFile {
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard directory layout of a course, resolved from a root.
|
||||
///
|
||||
/// Keeping the layout in one type means every command agrees on where things
|
||||
/// live, and a future rearrangement touches this struct only.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Layout {
|
||||
/// The course root.
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// Builds a layout from a course root directory.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `root` - the course directory.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The layout.
|
||||
pub fn new(root: impl Into<PathBuf>) -> Layout {
|
||||
Layout { root: root.into() }
|
||||
}
|
||||
|
||||
/// Path to `course.yaml`.
|
||||
pub fn course_file(&self) -> PathBuf {
|
||||
self.root.join(COURSE_FILE)
|
||||
}
|
||||
|
||||
/// Directory holding item bank YAML files.
|
||||
pub fn banks(&self) -> PathBuf {
|
||||
self.root.join("banks")
|
||||
}
|
||||
|
||||
/// Directory holding assessment records.
|
||||
pub fn assessments(&self) -> PathBuf {
|
||||
self.root.join("assessments")
|
||||
}
|
||||
|
||||
/// Directory holding response tables and derived statistics.
|
||||
pub fn data(&self) -> PathBuf {
|
||||
self.root.join("data")
|
||||
}
|
||||
|
||||
/// Directory holding generated reports.
|
||||
pub fn reports(&self) -> PathBuf {
|
||||
self.root.join("reports")
|
||||
}
|
||||
|
||||
/// Directory holding generated exports such as QTI packages.
|
||||
pub fn build(&self) -> PathBuf {
|
||||
self.root.join("build")
|
||||
}
|
||||
|
||||
/// Directory holding emitted JSON Schema files for editor validation.
|
||||
pub fn schema(&self) -> PathBuf {
|
||||
self.root.join("schema")
|
||||
}
|
||||
|
||||
/// Directory holding Typst export templates and their configuration.
|
||||
///
|
||||
/// Unlike the other directories, this one is *not* created by
|
||||
/// [`Layout::create_all`]. Its absence is meaningful: a course with no
|
||||
/// `templates/` directory uses the templates compiled into the binary, and
|
||||
/// creating an empty one on `init` would suggest a customization step is
|
||||
/// required when it is not. `coursebank template dump` creates it on demand.
|
||||
pub fn templates(&self) -> PathBuf {
|
||||
self.root.join("templates")
|
||||
}
|
||||
|
||||
/// Creates every directory in the layout.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Io`] if a directory cannot be created.
|
||||
pub fn create_all(&self) -> Result<()> {
|
||||
for dir in [
|
||||
self.root.clone(),
|
||||
self.banks(),
|
||||
self.assessments(),
|
||||
self.data(),
|
||||
self.reports(),
|
||||
self.build(),
|
||||
self.schema(),
|
||||
] {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercases and hyphenates a string for use in file names and ids.
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! The usage history of a course, derived by scanning assessment records.
|
||||
//!
|
||||
//! There is deliberately no separate ledger file. A ledger duplicates
|
||||
//! information the records already hold and then drifts away from it; this
|
||||
//! derives the same answers from the one artifact that has to be right anyway.
|
||||
//! An item was used exactly when it appears on a record.
|
||||
//!
|
||||
//! This is an aggregate over the records, the same kind of thing as
|
||||
//! [`crate::catalog::Catalog`], which is why it sits beside the schema rather
|
||||
//! than inside [`crate::assessment`].
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::assessment::{AssessmentFile, Kind};
|
||||
use crate::date::Date;
|
||||
use crate::error::Result;
|
||||
|
||||
/// One recorded appearance of an item, derived from the assessment records.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Usage {
|
||||
/// The item's global id.
|
||||
pub item: String,
|
||||
/// The assessment id it appeared on.
|
||||
pub assessment: String,
|
||||
/// The assessment title.
|
||||
pub title: String,
|
||||
/// The kind of assessment.
|
||||
pub kind: Kind,
|
||||
/// When it was administered, when recorded.
|
||||
pub date: Option<Date>,
|
||||
/// The printed question number.
|
||||
pub number: u32,
|
||||
/// The fingerprint as used.
|
||||
pub fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
/// The usage history of a course, built by scanning assessment records.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct History {
|
||||
/// Every recorded appearance, newest last.
|
||||
pub usages: Vec<Usage>,
|
||||
}
|
||||
|
||||
impl History {
|
||||
/// Builds the history from a directory of assessment records.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `dir` - the assessments directory.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The history.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates load errors.
|
||||
pub fn load(dir: &Path) -> Result<History> {
|
||||
let mut usages = Vec::new();
|
||||
for file in AssessmentFile::load_all(dir)? {
|
||||
for p in &file.items {
|
||||
usages.push(Usage {
|
||||
item: p.item.clone(),
|
||||
assessment: file.assessment.id.clone(),
|
||||
title: file.assessment.title.clone(),
|
||||
kind: file.assessment.kind,
|
||||
date: file.assessment.date,
|
||||
number: p.number,
|
||||
fingerprint: p.fingerprint.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
usages.sort_by(|a, b| {
|
||||
a.date
|
||||
.cmp(&b.date)
|
||||
.then(a.assessment.cmp(&b.assessment))
|
||||
.then(a.number.cmp(&b.number))
|
||||
});
|
||||
Ok(History { usages })
|
||||
}
|
||||
|
||||
/// Every appearance of one item, oldest first.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `uid` - the item's global id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Matching usages.
|
||||
pub fn for_item(&self, uid: &str) -> Vec<&Usage> {
|
||||
self.usages.iter().filter(|u| u.item == uid).collect()
|
||||
}
|
||||
|
||||
/// The most recent date an item was used.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `uid` - the item's global id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The date, or `None` when never used or never dated.
|
||||
pub fn last_used(&self, uid: &str) -> Option<Date> {
|
||||
self.for_item(uid).iter().filter_map(|u| u.date).max()
|
||||
}
|
||||
|
||||
/// How many times an item has been used.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `uid` - the item's global id.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The count.
|
||||
pub fn use_count(&self, uid: &str) -> usize {
|
||||
self.for_item(uid).len()
|
||||
}
|
||||
|
||||
/// Whether an item is still inside its reuse cooldown.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `uid` - the item's global id.
|
||||
/// * `cooldown_days` - the minimum gap between uses.
|
||||
/// * `as_of` - the date of the assessment being assembled.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` when the item was used too recently to reuse.
|
||||
pub fn in_cooldown(&self, uid: &str, cooldown_days: i64, as_of: Date) -> bool {
|
||||
match self.last_used(uid) {
|
||||
Some(last) => last.days_until(as_of) < cooldown_days,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn history_tracks_last_use_and_cooldown() {
|
||||
let h = History {
|
||||
usages: vec![
|
||||
Usage {
|
||||
item: "b::q-1".into(),
|
||||
assessment: "exam-1".into(),
|
||||
title: "Exam 1".into(),
|
||||
kind: Kind::Exam,
|
||||
date: Some("2025-09-15".parse().unwrap()),
|
||||
number: 4,
|
||||
fingerprint: None,
|
||||
},
|
||||
Usage {
|
||||
item: "b::q-1".into(),
|
||||
assessment: "exam-3".into(),
|
||||
title: "Exam 3".into(),
|
||||
kind: Kind::Exam,
|
||||
date: Some("2026-02-10".parse().unwrap()),
|
||||
number: 7,
|
||||
fingerprint: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
assert_eq!(h.use_count("b::q-1"), 2);
|
||||
assert_eq!(h.use_count("b::q-2"), 0);
|
||||
assert_eq!(h.last_used("b::q-1").unwrap().to_string(), "2026-02-10");
|
||||
|
||||
let exam_date: Date = "2026-04-23".parse().unwrap();
|
||||
// 72 days elapsed, so a 90-day cooldown still blocks it and a 60-day one
|
||||
// does not.
|
||||
assert!(h.in_cooldown("b::q-1", 90, exam_date));
|
||||
assert!(!h.in_cooldown("b::q-1", 60, exam_date));
|
||||
assert!(!h.in_cooldown("b::never-used", 3650, exam_date));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! The on-disk layout of a course directory.
|
||||
//!
|
||||
//! Keeping the layout in one type means every command agrees on where things
|
||||
//! live, and a future rearrangement touches this file only. It is deliberately
|
||||
//! separate from the course *schema* in [`crate::course`]: this module is about
|
||||
//! where files sit on disk, not what they contain.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::course::COURSE_FILE;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// The standard directory layout of a course, resolved from a root.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Layout {
|
||||
/// The course root.
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
/// Builds a layout from a course root directory.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `root` - the course directory.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The layout.
|
||||
pub fn new(root: impl Into<PathBuf>) -> Layout {
|
||||
Layout { root: root.into() }
|
||||
}
|
||||
|
||||
/// Path to `course.yaml`.
|
||||
pub fn course_file(&self) -> PathBuf {
|
||||
self.root.join(COURSE_FILE)
|
||||
}
|
||||
|
||||
/// Directory holding item bank YAML files.
|
||||
pub fn banks(&self) -> PathBuf {
|
||||
self.root.join("banks")
|
||||
}
|
||||
|
||||
/// Directory holding assessment records.
|
||||
pub fn assessments(&self) -> PathBuf {
|
||||
self.root.join("assessments")
|
||||
}
|
||||
|
||||
/// Directory holding response tables and derived statistics.
|
||||
pub fn data(&self) -> PathBuf {
|
||||
self.root.join("data")
|
||||
}
|
||||
|
||||
/// Directory holding generated reports.
|
||||
pub fn reports(&self) -> PathBuf {
|
||||
self.root.join("reports")
|
||||
}
|
||||
|
||||
/// Directory holding generated exports such as QTI packages.
|
||||
pub fn build(&self) -> PathBuf {
|
||||
self.root.join("build")
|
||||
}
|
||||
|
||||
/// Directory holding emitted JSON Schema files for editor validation.
|
||||
pub fn schema(&self) -> PathBuf {
|
||||
self.root.join("schema")
|
||||
}
|
||||
|
||||
/// Directory holding Typst export templates and their configuration.
|
||||
///
|
||||
/// Unlike the other directories, this one is *not* created by
|
||||
/// [`Layout::create_all`]. Its absence is meaningful: a course with no
|
||||
/// `templates/` directory uses the templates compiled into the binary, and
|
||||
/// creating an empty one on `init` would suggest a customization step is
|
||||
/// required when it is not. `coursebank template dump` creates it on demand.
|
||||
pub fn templates(&self) -> PathBuf {
|
||||
self.root.join("templates")
|
||||
}
|
||||
|
||||
/// Creates every directory in the layout.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Io`] if a directory cannot be created.
|
||||
pub fn create_all(&self) -> Result<()> {
|
||||
for dir in [
|
||||
self.root.clone(),
|
||||
self.banks(),
|
||||
self.assessments(),
|
||||
self.data(),
|
||||
self.reports(),
|
||||
self.build(),
|
||||
self.schema(),
|
||||
] {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -137,9 +137,9 @@ pub fn sha256(msg: &[u8]) -> [u8; 32] {
|
||||
|
||||
let mut w = [0u32; 64];
|
||||
for chunk in data.chunks(64) {
|
||||
for i in 0..16 {
|
||||
for (i, w_i) in w.iter_mut().enumerate().take(16) {
|
||||
let j = i * 4;
|
||||
w[i] = u32::from_be_bytes([chunk[j], chunk[j + 1], chunk[j + 2], chunk[j + 3]]);
|
||||
*w_i = u32::from_be_bytes([chunk[j], chunk[j + 1], chunk[j + 2], chunk[j + 3]]);
|
||||
}
|
||||
for i in 16..64 {
|
||||
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
|
||||
|
||||
+1
-1
@@ -292,7 +292,7 @@ fn wrap_delimited(s: &str, delim: &str, pre: &str, post: &str) -> String {
|
||||
out.push_str(rest);
|
||||
return out;
|
||||
}
|
||||
Some(j) if j == 0 => {
|
||||
Some(0) => {
|
||||
// Empty span such as `**`; emit literally and move on.
|
||||
out.push_str(&rest[..i + delim.len()]);
|
||||
rest = after;
|
||||
|
||||
Reference in New Issue
Block a user