1348 lines
47 KiB
Rust
1348 lines
47 KiB
Rust
// SPDX-License-Identifier: Prosperity-3.0.0
|
|
// Copyright Scientific Computing Studio
|
|
// Source: https://git.scient.ing/education/coursebank
|
|
|
|
//! Handing a diagnostic to a Typst template.
|
|
//!
|
|
//! The same arrangement the exam export uses: this module decides what the
|
|
//! template is told, the template decides what it looks like, and the two meet at
|
|
//! a marker comment. Nothing here knows about page size or colour.
|
|
//!
|
|
//! Two slots are filled, both already defined for the exam path:
|
|
//!
|
|
//! | Slot | Injected |
|
|
//! |:--|:--|
|
|
//! | `meta` | `#let cb-meta = (...)` — course, assessment, generator, class size |
|
|
//! | `data` | `#let cb-data = (...)` — one student's diagnostic, or the class's |
|
|
//!
|
|
//! The `questions` slot is deliberately unused. It exists to emit question stems,
|
|
//! and a diagnostic has none to emit: see
|
|
//! [`crate::diagnostic::StudentDiagnostic`], which has no field for one. A student
|
|
//! report template that wanted to print a stem would have nothing to print it
|
|
//! from, which is the property worth preserving.
|
|
//!
|
|
//! Prose fields — objective text, the feedback written for a chosen option, a
|
|
//! reading's focus sentence — go through the same markup path as an exam stem, so
|
|
//! `$\Delta G$` in `course.yaml` renders the same way in a report as it does on
|
|
//! the paper.
|
|
|
|
use crate::assessment::AssessmentFile;
|
|
use crate::catalog::Catalog;
|
|
use crate::diagnostic::{
|
|
Bin, CohortDiagnostic, CohortObjectiveRow, CohortQuestionRow, StudentDiagnostic, StudyGroup,
|
|
};
|
|
use crate::error::{Error, Result};
|
|
use crate::layout::Layout;
|
|
use crate::typst::config::{RenderConfig, Variant};
|
|
use crate::typst::payload::markup_value;
|
|
use crate::typst::template::{self, Origin, Slot};
|
|
use crate::typst::value::Value;
|
|
|
|
/// A rendered diagnostic document.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Document {
|
|
/// Which document this is.
|
|
pub variant: Variant,
|
|
/// Where the template came from.
|
|
pub origin: Origin,
|
|
/// Which slots it declared.
|
|
pub slots: Vec<Slot>,
|
|
/// The Typst source.
|
|
pub text: String,
|
|
/// Advisory problems.
|
|
pub warnings: Vec<String>,
|
|
}
|
|
|
|
/// The identity block every diagnostic carries.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Meta {
|
|
/// Course code.
|
|
pub course_code: String,
|
|
/// Course title.
|
|
pub course_title: String,
|
|
/// The term.
|
|
pub term: String,
|
|
/// Institution, when the course names one.
|
|
pub institution: Option<String>,
|
|
/// Assessment id.
|
|
pub assessment_id: String,
|
|
/// Assessment title.
|
|
pub assessment_title: String,
|
|
/// The administration date, as `YYYY-MM-DD`.
|
|
pub date: Option<String>,
|
|
/// The day the report was generated.
|
|
pub generated_on: String,
|
|
/// The tool version, so a report found later can be traced.
|
|
pub version: String,
|
|
/// How many students sat the assessment.
|
|
pub n_students: usize,
|
|
/// The course's mastery threshold, so a template can draw the line in the
|
|
/// same place the classification used.
|
|
pub mastery_threshold: f64,
|
|
/// How many items an objective needs before it is classified at all.
|
|
pub min_items_for_mastery: usize,
|
|
}
|
|
|
|
impl Meta {
|
|
/// Builds the identity block.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `catalog` - the loaded course.
|
|
/// * `record` - the assessment record.
|
|
/// * `n_students` - the cohort size.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// The block.
|
|
pub fn new(catalog: &Catalog, record: &AssessmentFile, n_students: usize) -> Meta {
|
|
Meta {
|
|
course_code: catalog.course.course.code.clone(),
|
|
course_title: catalog.course.course.title.clone(),
|
|
term: record
|
|
.assessment
|
|
.term
|
|
.clone()
|
|
.unwrap_or_else(|| catalog.course.course.term.clone()),
|
|
institution: catalog.course.course.institution.clone(),
|
|
assessment_id: record.assessment.id.clone(),
|
|
assessment_title: record.assessment.title.clone(),
|
|
date: record.assessment.date.map(|d| d.to_string()),
|
|
generated_on: crate::date::Date::today().to_string(),
|
|
version: crate::VERSION.to_string(),
|
|
n_students,
|
|
mastery_threshold: catalog.course.policy.mastery_threshold,
|
|
min_items_for_mastery: catalog.course.policy.min_items_for_mastery,
|
|
}
|
|
}
|
|
|
|
/// The metadata as a Typst value.
|
|
fn value(&self, config: &RenderConfig) -> Value {
|
|
let mut course = Value::dict();
|
|
course.insert("code", Value::str(&self.course_code));
|
|
course.insert("title", Value::str(&self.course_title));
|
|
course.insert("term", Value::str(&self.term));
|
|
if let Some(institution) = &self.institution {
|
|
course.insert("institution", Value::str(institution));
|
|
}
|
|
|
|
let mut assessment = Value::dict();
|
|
assessment.insert("id", Value::str(&self.assessment_id));
|
|
assessment.insert("title", Value::str(&self.assessment_title));
|
|
assessment.insert_some("date", self.date.as_ref().map(Value::str));
|
|
|
|
let mut generator = Value::dict();
|
|
generator.insert("tool", Value::str("coursebank"));
|
|
generator.insert("version", Value::str(&self.version));
|
|
generator.insert("on", Value::str(&self.generated_on));
|
|
|
|
let mut policy = Value::dict();
|
|
policy.insert("mastery-threshold", Value::Float(self.mastery_threshold));
|
|
policy.insert(
|
|
"min-items-for-mastery",
|
|
Value::Int(self.min_items_for_mastery as i64),
|
|
);
|
|
|
|
let mut out = Value::dict();
|
|
out.insert("course", course);
|
|
out.insert("assessment", assessment);
|
|
out.insert("generator", generator);
|
|
out.insert("policy", policy);
|
|
// `students-tested` is the honest name: it is how many people sat this
|
|
// assessment, which is not the enrolment. `class-size` stays as an alias
|
|
// so a template forked before this change keeps working.
|
|
out.insert("students-tested", Value::Int(self.n_students as i64));
|
|
out.insert("class-size", Value::Int(self.n_students as i64));
|
|
out.insert("extra", extra_value(config));
|
|
out
|
|
}
|
|
}
|
|
|
|
/// The render config's `extra` block, carried through untouched.
|
|
fn extra_value(config: &RenderConfig) -> Value {
|
|
let mut out = Value::dict();
|
|
for (key, value) in &config.extra {
|
|
out.insert(key.clone(), crate::typst::value::from_yaml(value, false));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// One student's diagnostic as a Typst value.
|
|
///
|
|
/// `config.student_sections` decides which of `levels`, `objectives`,
|
|
/// `strengths`, `focus`, `dropped-questions`, `review-lectures`, and `study`
|
|
/// are populated; a section turned off is emitted as an empty array rather
|
|
/// than left out of the dictionary, so a template need not guard against a
|
|
/// missing key.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `diagnostic` - the assembled diagnostic.
|
|
/// * `config` - the render config, for markup handling and section toggles.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// A dictionary the template binds as `cb-data`.
|
|
pub fn student_value(diagnostic: &StudentDiagnostic, config: &RenderConfig) -> Value {
|
|
let content = config.content.is_content();
|
|
let mut out = Value::dict();
|
|
|
|
out.insert("student-key", Value::str(&diagnostic.student_key));
|
|
out.insert_some("name", diagnostic.name.as_ref().map(Value::str));
|
|
out.insert_some("sid", diagnostic.sid.as_ref().map(Value::str));
|
|
out.insert_some("email", diagnostic.email.as_ref().map(Value::str));
|
|
out.insert_some("form", diagnostic.form.as_ref().map(Value::str));
|
|
|
|
let mut score = Value::dict();
|
|
score.insert("points", Value::Float(diagnostic.score.points));
|
|
score.insert("possible", Value::Float(diagnostic.score.points_possible));
|
|
score.insert("percent", Value::Float(diagnostic.score.percent));
|
|
score.insert("bonus", Value::Float(diagnostic.score.bonus_points));
|
|
score.insert("correct", Value::Int(diagnostic.score.correct as i64));
|
|
score.insert("items", Value::Int(diagnostic.score.n_items as i64));
|
|
out.insert("score", score);
|
|
|
|
if let Some(standing) = &diagnostic.standing {
|
|
let mut value = Value::dict();
|
|
value.insert("class-mean", Value::Float(standing.class_mean));
|
|
value.insert("class-sd", Value::Float(standing.class_sd));
|
|
value.insert("band", Value::str(&standing.band));
|
|
value.insert_some("theta", standing.theta.map(Value::Float));
|
|
value.insert_some("theta-se", standing.theta_se.map(Value::Float));
|
|
out.insert("standing", value);
|
|
}
|
|
|
|
let levels = if config.student_sections.levels {
|
|
diagnostic.levels.as_slice()
|
|
} else {
|
|
&[]
|
|
};
|
|
out.insert(
|
|
"levels",
|
|
Value::Array(
|
|
levels
|
|
.iter()
|
|
.map(|level| {
|
|
let mut value = Value::dict();
|
|
value.insert("level", Value::Int(level.level as i64));
|
|
value.insert("name", Value::str(&level.name));
|
|
value.insert("blurb", Value::str(&level.blurb));
|
|
value.insert("items", Value::Int(level.n_items as i64));
|
|
value.insert("rate", Value::Float(level.rate));
|
|
value.insert_some("class-rate", level.class_rate.map(Value::Float));
|
|
value.insert_some("comparison", level.comparison.as_ref().map(Value::str));
|
|
value
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
|
|
let objectives = if config.student_sections.objectives {
|
|
diagnostic.objectives.as_slice()
|
|
} else {
|
|
&[]
|
|
};
|
|
out.insert(
|
|
"objectives",
|
|
Value::Array(
|
|
objectives
|
|
.iter()
|
|
.map(|objective| {
|
|
let mut value = Value::dict();
|
|
value.insert("id", Value::str(&objective.id));
|
|
value.insert("text", markup_value(&objective.text, content));
|
|
value.insert_some("unit", objective.unit.as_ref().map(Value::str));
|
|
value.insert("items", Value::Int(objective.n_items as i64));
|
|
value.insert("credit", Value::Float(objective.credit));
|
|
value.insert("rate", Value::Float(objective.rate));
|
|
value.insert("lower", Value::Float(objective.lower));
|
|
value.insert("upper", Value::Float(objective.upper));
|
|
value.insert_some("class-rate", objective.class_rate.map(Value::Float));
|
|
value.insert("status", Value::str(&objective.status));
|
|
value.insert("symbol", Value::str(&objective.symbol));
|
|
value.insert("confident", Value::Bool(objective.confident));
|
|
value.insert("thin-evidence", Value::Bool(objective.thin_evidence));
|
|
value.insert(
|
|
"levels",
|
|
Value::Array(
|
|
objective
|
|
.levels
|
|
.iter()
|
|
.map(|l| Value::Int(*l as i64))
|
|
.collect(),
|
|
),
|
|
);
|
|
value
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
|
|
let objective_refs = |rows: &[crate::diagnostic::ObjectiveRef]| -> Value {
|
|
Value::Array(
|
|
rows.iter()
|
|
.map(|row| {
|
|
let mut value = Value::dict();
|
|
value.insert("id", Value::str(&row.id));
|
|
value.insert("text", markup_value(&row.text, content));
|
|
value.insert("rate", Value::Float(row.rate));
|
|
value.insert("items", Value::Int(row.n_items as i64));
|
|
value
|
|
})
|
|
.collect(),
|
|
)
|
|
};
|
|
out.insert(
|
|
"strengths",
|
|
objective_refs(if config.student_sections.strengths {
|
|
&diagnostic.strengths
|
|
} else {
|
|
&[]
|
|
}),
|
|
);
|
|
out.insert(
|
|
"focus",
|
|
objective_refs(if config.student_sections.focus {
|
|
&diagnostic.focus
|
|
} else {
|
|
&[]
|
|
}),
|
|
);
|
|
|
|
out.insert(
|
|
"questions",
|
|
Value::Array(
|
|
diagnostic
|
|
.questions
|
|
.iter()
|
|
.map(|question| {
|
|
let mut value = Value::dict();
|
|
value.insert("number", Value::Int(question.number as i64));
|
|
value.insert_some("position", question.position.map(|p| Value::Int(p as i64)));
|
|
value.insert_some("level", question.level.map(|l| Value::Int(l as i64)));
|
|
value.insert(
|
|
"targets",
|
|
Value::Array(question.targets.iter().map(Value::str).collect()),
|
|
);
|
|
value.insert_some("correct", question.correct.map(Value::Bool));
|
|
value.insert("credit", Value::Float(question.credit));
|
|
value.insert("bonus", Value::Bool(question.bonus));
|
|
value.insert("dropped", Value::Bool(question.dropped));
|
|
value.insert("blank", Value::Bool(question.blank));
|
|
value.insert_some("class-rate", question.class_rate.map(Value::Float));
|
|
// Both tiers, as a list of pairs: the target says what this
|
|
// question asked, the objective says which row of the table
|
|
// above it counted toward. `objective` is absent when the
|
|
// tagged id is an objective with no targets, so the template
|
|
// does not print one sentence twice.
|
|
value.insert(
|
|
"measured",
|
|
Value::Array(
|
|
question
|
|
.measured
|
|
.iter()
|
|
.map(|m| {
|
|
let mut pair = Value::dict();
|
|
pair.insert_some(
|
|
"objective",
|
|
m.objective
|
|
.as_ref()
|
|
.map(|text| markup_value(text, content)),
|
|
);
|
|
pair.insert("target", markup_value(&m.target, content));
|
|
pair
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
for (key, text) in [
|
|
("feedback", question.feedback.as_ref()),
|
|
("hint", question.hint.as_ref()),
|
|
("misconception", question.misconception.as_ref()),
|
|
("worked", question.worked.as_ref()),
|
|
] {
|
|
value.insert_some(key, text.map(|t| markup_value(t, content)));
|
|
}
|
|
value.insert(
|
|
"taught-in",
|
|
Value::Array(question.taught_in.iter().map(Value::str).collect()),
|
|
);
|
|
value.insert(
|
|
"review",
|
|
Value::Array(
|
|
question
|
|
.review
|
|
.iter()
|
|
.map(|reading| {
|
|
let mut entry = Value::dict();
|
|
entry.insert("citation", Value::str(&reading.citation));
|
|
entry.insert_some(
|
|
"title",
|
|
reading.title.as_ref().map(Value::str),
|
|
);
|
|
entry.insert_some("url", reading.url.as_ref().map(Value::str));
|
|
entry
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
value
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
|
|
let dropped_questions = if config.student_sections.dropped_questions {
|
|
diagnostic.dropped_questions.as_slice()
|
|
} else {
|
|
&[]
|
|
};
|
|
out.insert(
|
|
"dropped-questions",
|
|
Value::Array(
|
|
dropped_questions
|
|
.iter()
|
|
.map(|dropped| {
|
|
let mut value = Value::dict();
|
|
value.insert("number", Value::Int(dropped.number as i64));
|
|
value.insert("full-credit", Value::Bool(dropped.full_credit));
|
|
value
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
|
|
let review_lectures = if config.student_sections.review_lectures {
|
|
diagnostic.review_lectures.as_slice()
|
|
} else {
|
|
&[]
|
|
};
|
|
out.insert(
|
|
"review-lectures",
|
|
Value::Array(
|
|
review_lectures
|
|
.iter()
|
|
.map(|lecture| {
|
|
let mut value = Value::dict();
|
|
value.insert("lecture", Value::str(&lecture.lecture));
|
|
value.insert("title", Value::str(&lecture.title));
|
|
value.insert_some("url", lecture.url.as_ref().map(Value::str));
|
|
value.insert("targets-missed", Value::Int(lecture.n_targets as i64));
|
|
value.insert("questions-missed", Value::Int(lecture.n_questions as i64));
|
|
value.insert(
|
|
"questions",
|
|
Value::Array(
|
|
lecture
|
|
.questions
|
|
.iter()
|
|
.map(|n| Value::Int(*n as i64))
|
|
.collect(),
|
|
),
|
|
);
|
|
value.insert(
|
|
"slides",
|
|
Value::Array(
|
|
lecture
|
|
.slides
|
|
.iter()
|
|
.map(|n| Value::Int(*n as i64))
|
|
.collect(),
|
|
),
|
|
);
|
|
value.insert(
|
|
"targets",
|
|
Value::Array(
|
|
lecture
|
|
.targets
|
|
.iter()
|
|
.map(|text| markup_value(text, content))
|
|
.collect(),
|
|
),
|
|
);
|
|
value
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
|
|
let study = if config.student_sections.study {
|
|
diagnostic.study.as_slice()
|
|
} else {
|
|
&[]
|
|
};
|
|
out.insert(
|
|
"study",
|
|
Value::Array(
|
|
study
|
|
.iter()
|
|
.map(|group| study_value(group, content))
|
|
.collect(),
|
|
),
|
|
);
|
|
|
|
out
|
|
}
|
|
|
|
/// One study group as a Typst value.
|
|
fn study_value(group: &StudyGroup, content: bool) -> Value {
|
|
let mut out = Value::dict();
|
|
out.insert("objective", Value::str(&group.objective));
|
|
out.insert("text", markup_value(&group.text, content));
|
|
out.insert("rate", Value::Float(group.rate));
|
|
out.insert(
|
|
"readings",
|
|
Value::Array(
|
|
group
|
|
.readings
|
|
.iter()
|
|
.map(|reading| {
|
|
let mut value = Value::dict();
|
|
value.insert("citation", Value::str(&reading.citation));
|
|
value.insert("lecture", Value::str(&reading.lecture));
|
|
value.insert("lecture-title", Value::str(&reading.lecture_title));
|
|
value.insert_some("url", reading.url.as_ref().map(Value::str));
|
|
value.insert_some(
|
|
"focus",
|
|
reading.focus.as_ref().map(|t| markup_value(t, content)),
|
|
);
|
|
value.insert_some(
|
|
"summary",
|
|
reading.summary.as_ref().map(|t| markup_value(t, content)),
|
|
);
|
|
value.insert("supplemental", Value::Bool(reading.supplemental));
|
|
value
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
out
|
|
}
|
|
|
|
/// The class diagnostic as a Typst value.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `diagnostic` - the assembled diagnostic.
|
|
/// * `config` - the render config, for markup handling.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// A dictionary the template binds as `cb-data`.
|
|
pub fn cohort_value(diagnostic: &CohortDiagnostic, config: &RenderConfig) -> Value {
|
|
let content = config.content.is_content();
|
|
let mut out = Value::dict();
|
|
|
|
out.insert("students", Value::Int(diagnostic.n_students as i64));
|
|
out.insert("items", Value::Int(diagnostic.n_items as i64));
|
|
|
|
let mut distribution = Value::dict();
|
|
distribution.insert("mean", Value::Float(diagnostic.distribution.mean));
|
|
distribution.insert("median", Value::Float(diagnostic.distribution.median));
|
|
distribution.insert("sd", Value::Float(diagnostic.distribution.sd));
|
|
distribution.insert("min", Value::Float(diagnostic.distribution.min));
|
|
distribution.insert("max", Value::Float(diagnostic.distribution.max));
|
|
distribution.insert(
|
|
"bins",
|
|
Value::Array(diagnostic.distribution.bins.iter().map(bin_value).collect()),
|
|
);
|
|
out.insert("distribution", distribution);
|
|
|
|
let mut reliability = Value::dict();
|
|
reliability.insert_some("alpha", diagnostic.reliability.alpha.map(Value::Float));
|
|
reliability.insert_some("sem", diagnostic.reliability.sem.map(Value::Float));
|
|
reliability.insert("mean-p", Value::Float(diagnostic.reliability.mean_p));
|
|
reliability.insert_some(
|
|
"mean-point-biserial",
|
|
diagnostic.reliability.mean_point_biserial.map(Value::Float),
|
|
);
|
|
reliability.insert(
|
|
"interpretation",
|
|
Value::str(&diagnostic.reliability.interpretation),
|
|
);
|
|
out.insert("reliability", reliability);
|
|
|
|
out.insert(
|
|
"levels",
|
|
Value::Array(
|
|
diagnostic
|
|
.levels
|
|
.iter()
|
|
.map(|level| {
|
|
let mut value = Value::dict();
|
|
value.insert("level", Value::Int(level.level as i64));
|
|
value.insert("name", Value::str(&level.name));
|
|
value.insert("items", Value::Int(level.n_items as i64));
|
|
value.insert("rate", Value::Float(level.rate));
|
|
value
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
|
|
out.insert(
|
|
"objectives",
|
|
Value::Array(
|
|
diagnostic
|
|
.objectives
|
|
.iter()
|
|
.map(|o| cohort_objective_value(o, content))
|
|
.collect(),
|
|
),
|
|
);
|
|
out.insert(
|
|
"gaps",
|
|
Value::Array(
|
|
diagnostic
|
|
.gaps
|
|
.iter()
|
|
.map(|o| cohort_objective_value(o, content))
|
|
.collect(),
|
|
),
|
|
);
|
|
|
|
out.insert(
|
|
"questions",
|
|
Value::Array(
|
|
diagnostic
|
|
.questions
|
|
.iter()
|
|
.map(|q| cohort_question_value(q, content))
|
|
.collect(),
|
|
),
|
|
);
|
|
out.insert(
|
|
"revise",
|
|
Value::Array(
|
|
diagnostic
|
|
.revise
|
|
.iter()
|
|
.map(|q| cohort_question_value(q, content))
|
|
.collect(),
|
|
),
|
|
);
|
|
// Separate from `questions` so no statistic can pick them up, and merged
|
|
// back in by the evidence section, which describes rather than measures.
|
|
out.insert(
|
|
"dropped-detail",
|
|
Value::Array(
|
|
diagnostic
|
|
.dropped_detail
|
|
.iter()
|
|
.map(|q| cohort_question_value(q, content))
|
|
.collect(),
|
|
),
|
|
);
|
|
|
|
out.insert(
|
|
"grades",
|
|
Value::Array(
|
|
diagnostic
|
|
.grades
|
|
.iter()
|
|
.map(|grade| {
|
|
let mut value = Value::dict();
|
|
value.insert("letter", Value::str(&grade.letter));
|
|
value.insert("low", Value::Float(grade.low));
|
|
value.insert("high", Value::Float(grade.high));
|
|
value.insert_some("gpa", grade.gpa.map(Value::Float));
|
|
value.insert_some("attainment", grade.attainment.as_ref().map(Value::str));
|
|
value.insert("group", Value::str(&grade.group));
|
|
value.insert("count", Value::Int(grade.count as i64));
|
|
value.insert("share", Value::Float(grade.share));
|
|
value.insert("at-or-above", Value::Int(grade.at_or_above as i64));
|
|
value
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
|
|
out.insert(
|
|
"lectures",
|
|
Value::Array(
|
|
diagnostic
|
|
.lectures
|
|
.iter()
|
|
.map(|lecture| {
|
|
let mut value = Value::dict();
|
|
value.insert("lecture", Value::str(&lecture.lecture));
|
|
value.insert("title", Value::str(&lecture.title));
|
|
value.insert("items", Value::Int(lecture.n_items as i64));
|
|
value.insert("objectives", Value::Int(lecture.n_objectives as i64));
|
|
value.insert(
|
|
"objectives-below",
|
|
Value::Int(lecture.n_objectives_below as i64),
|
|
);
|
|
value.insert("rate", Value::Float(lecture.rate));
|
|
value.insert(
|
|
"questions",
|
|
Value::Array(
|
|
lecture
|
|
.questions
|
|
.iter()
|
|
.map(|n| Value::Int(*n as i64))
|
|
.collect(),
|
|
),
|
|
);
|
|
value.insert_some(
|
|
"worst-objective",
|
|
lecture
|
|
.worst_objective
|
|
.as_ref()
|
|
.map(|text| markup_value(text, content)),
|
|
);
|
|
value
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
|
|
out.insert(
|
|
"dropped-questions",
|
|
Value::Array(
|
|
diagnostic
|
|
.dropped_questions
|
|
.iter()
|
|
.map(|dropped| {
|
|
let mut value = Value::dict();
|
|
value.insert("number", Value::Int(dropped.number as i64));
|
|
value.insert("full-credit", Value::Bool(dropped.full_credit));
|
|
value
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
|
|
let triage_rows = |rows: &[crate::diagnostic::TriageRow]| -> Value {
|
|
Value::Array(rows.iter().map(|row| triage_value(row, content)).collect())
|
|
};
|
|
let mut triage = Value::dict();
|
|
triage.insert("discard", triage_rows(&diagnostic.triage.discard));
|
|
triage.insert("rekey", triage_rows(&diagnostic.triage.rekey));
|
|
triage.insert("revise", triage_rows(&diagnostic.triage.revise));
|
|
triage.insert("reteach", triage_rows(&diagnostic.triage.reteach));
|
|
triage.insert("bounded", triage_rows(&diagnostic.triage.bounded));
|
|
triage.insert("clean", Value::Int(diagnostic.triage.clean as i64));
|
|
out.insert("triage", triage);
|
|
|
|
let predictions = &diagnostic.predictions;
|
|
let mut prediction = Value::dict();
|
|
prediction.insert("predicted", Value::Int(predictions.n_predicted as i64));
|
|
prediction.insert("calibrated", Value::Int(predictions.n_calibrated as i64));
|
|
prediction.insert_some(
|
|
"mean-signed-error",
|
|
predictions.mean_signed_error.map(Value::Float),
|
|
);
|
|
prediction.insert_some(
|
|
"mean-abs-error",
|
|
predictions.mean_abs_error.map(Value::Float),
|
|
);
|
|
prediction.insert("within", Value::Int(predictions.n_within as i64));
|
|
prediction.insert("band", Value::Int(predictions.n_band as i64));
|
|
prediction.insert("band-hit", Value::Int(predictions.n_band_hit as i64));
|
|
if let Some((number, expected, observed)) = predictions.biggest_surprise {
|
|
let mut surprise = Value::dict();
|
|
surprise.insert("number", Value::Int(number as i64));
|
|
surprise.insert("expected", Value::Float(expected));
|
|
surprise.insert("observed", Value::Float(observed));
|
|
prediction.insert("biggest-surprise", surprise);
|
|
}
|
|
out.insert("predictions", prediction);
|
|
|
|
out.insert(
|
|
"forms",
|
|
Value::Array(
|
|
diagnostic
|
|
.forms
|
|
.iter()
|
|
.map(|form| {
|
|
let mut value = Value::dict();
|
|
value.insert("id", Value::str(&form.id));
|
|
value.insert("students", Value::Int(form.n_students as i64));
|
|
value.insert("mean", Value::Float(form.mean));
|
|
value.insert("sd", Value::Float(form.sd));
|
|
value
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
|
|
out.insert(
|
|
"blueprint",
|
|
Value::Array(diagnostic.blueprint.iter().map(Value::str).collect()),
|
|
);
|
|
|
|
out.insert(
|
|
"patterns",
|
|
Value::Array(
|
|
diagnostic
|
|
.patterns
|
|
.iter()
|
|
.map(|pattern| {
|
|
let mut value = Value::dict();
|
|
value.insert("label", Value::str(&pattern.label));
|
|
value.insert("students", Value::Int(pattern.n_students as i64));
|
|
let mut means = Value::dict();
|
|
for (level, mean) in &pattern.level_means {
|
|
means.insert(format!("l{level}"), Value::Float(*mean));
|
|
}
|
|
value.insert("level-means", means);
|
|
value
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
|
|
out.insert(
|
|
"warnings",
|
|
Value::Array(diagnostic.warnings.iter().map(Value::str).collect()),
|
|
);
|
|
|
|
out
|
|
}
|
|
|
|
/// One triage row as a Typst value.
|
|
fn triage_value(row: &crate::diagnostic::TriageRow, content: bool) -> Value {
|
|
let mut value = Value::dict();
|
|
value.insert("number", Value::Int(row.number as i64));
|
|
value.insert_some("item", row.item.as_ref().map(Value::str));
|
|
value.insert_some("level", row.level.map(|l| Value::Int(l as i64)));
|
|
value.insert("p", Value::Float(row.p_value));
|
|
value.insert_some("point-biserial", row.point_biserial.map(Value::Float));
|
|
value.insert_some("discrimination", row.discrimination.map(Value::Float));
|
|
value.insert(
|
|
"targets",
|
|
Value::Array(
|
|
row.targets
|
|
.iter()
|
|
.map(|text| markup_value(text, content))
|
|
.collect(),
|
|
),
|
|
);
|
|
value.insert(
|
|
"taught-in",
|
|
Value::Array(row.taught_in.iter().map(Value::str).collect()),
|
|
);
|
|
value.insert_some("option", row.option.as_ref().map(Value::str));
|
|
value.insert_some("option-share", row.option_share.map(Value::Float));
|
|
value.insert_some(
|
|
"option-point-biserial",
|
|
row.option_point_biserial.map(Value::Float),
|
|
);
|
|
value.insert(
|
|
"reasons",
|
|
Value::Array(
|
|
row.reasons
|
|
.iter()
|
|
.map(|reason| markup_value(reason, content))
|
|
.collect(),
|
|
),
|
|
);
|
|
value
|
|
}
|
|
|
|
/// One histogram bin as a Typst value.
|
|
fn bin_value(bin: &Bin) -> Value {
|
|
let mut value = Value::dict();
|
|
value.insert("low", Value::Int(bin.low as i64));
|
|
value.insert("high", Value::Int(bin.high as i64));
|
|
value.insert("count", Value::Int(bin.count as i64));
|
|
value
|
|
}
|
|
|
|
/// One class objective row as a Typst value.
|
|
fn cohort_objective_value(objective: &CohortObjectiveRow, content: bool) -> Value {
|
|
let mut value = Value::dict();
|
|
value.insert("id", Value::str(&objective.id));
|
|
value.insert("text", markup_value(&objective.text, content));
|
|
value.insert("items", Value::Int(objective.n_items as i64));
|
|
value.insert("rate", Value::Float(objective.rate));
|
|
value.insert("meeting", Value::Int(objective.meeting as i64));
|
|
value.insert("developing", Value::Int(objective.developing as i64));
|
|
value.insert("not-yet", Value::Int(objective.not_yet as i64));
|
|
value.insert("thin", Value::Int(objective.thin as i64));
|
|
value.insert("below-threshold", Value::Bool(objective.below_threshold));
|
|
value
|
|
}
|
|
|
|
/// One class question row as a Typst value.
|
|
fn cohort_question_value(question: &CohortQuestionRow, content: bool) -> Value {
|
|
let mut value = Value::dict();
|
|
value.insert("number", Value::Int(question.number as i64));
|
|
value.insert_some("item", question.item.as_ref().map(Value::str));
|
|
value.insert_some("level", question.level.map(|l| Value::Int(l as i64)));
|
|
value.insert(
|
|
"targets",
|
|
Value::Array(question.targets.iter().map(Value::str).collect()),
|
|
);
|
|
value.insert(
|
|
"target-texts",
|
|
Value::Array(
|
|
question
|
|
.target_texts
|
|
.iter()
|
|
.map(|text| markup_value(text, content))
|
|
.collect(),
|
|
),
|
|
);
|
|
value.insert_some(
|
|
"stem",
|
|
question
|
|
.stem
|
|
.as_ref()
|
|
.map(|text| markup_value(text, content)),
|
|
);
|
|
value.insert("dropped", Value::Bool(question.dropped));
|
|
value.insert(
|
|
"dropped-full-credit",
|
|
Value::Bool(question.dropped_full_credit),
|
|
);
|
|
value.insert(
|
|
"taught-in",
|
|
Value::Array(question.taught_in.iter().map(Value::str).collect()),
|
|
);
|
|
value.insert(
|
|
"lectures",
|
|
Value::Array(question.lectures.iter().map(Value::str).collect()),
|
|
);
|
|
value.insert("difficulty-band", Value::str(&question.difficulty_band));
|
|
value.insert(
|
|
"discrimination-band",
|
|
Value::str(&question.discrimination_band),
|
|
);
|
|
value.insert("p", Value::Float(question.p_value));
|
|
value.insert_some("point-biserial", question.point_biserial.map(Value::Float));
|
|
value.insert_some("discrimination", question.discrimination.map(Value::Float));
|
|
value.insert("blank-rate", Value::Float(question.blank_rate));
|
|
value.insert(
|
|
"key",
|
|
Value::Array(question.key.iter().map(Value::str).collect()),
|
|
);
|
|
value.insert(
|
|
"options",
|
|
Value::Array(
|
|
question
|
|
.options
|
|
.iter()
|
|
.map(|option| {
|
|
let mut value = Value::dict();
|
|
value.insert("letter", Value::str(&option.letter));
|
|
value.insert_some(
|
|
"text",
|
|
option.text.as_ref().map(|text| markup_value(text, content)),
|
|
);
|
|
// The letter on each paper, so a statistic reported against
|
|
// the bank letter can be checked against a student's copy.
|
|
value.insert(
|
|
"printed",
|
|
Value::Array(
|
|
option
|
|
.printed
|
|
.iter()
|
|
.map(|printed| {
|
|
let mut pair = Value::dict();
|
|
pair.insert("form", Value::str(&printed.form));
|
|
pair.insert("letter", Value::str(&printed.letter));
|
|
pair
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
value.insert("count", Value::Int(option.count as i64));
|
|
value.insert("rate", Value::Float(option.rate));
|
|
value.insert("is-key", Value::Bool(option.is_key));
|
|
value.insert_some("point-biserial", option.point_biserial.map(Value::Float));
|
|
value.insert("nonfunctioning", Value::Bool(option.nonfunctioning));
|
|
value
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
value.insert(
|
|
"flags",
|
|
Value::Array(question.flags.iter().map(Value::str).collect()),
|
|
);
|
|
value.insert(
|
|
"notes",
|
|
Value::Array(
|
|
question
|
|
.notes
|
|
.iter()
|
|
.map(|n| markup_value(n, content))
|
|
.collect(),
|
|
),
|
|
);
|
|
value.insert(
|
|
"prediction-notes",
|
|
Value::Array(
|
|
question
|
|
.prediction_notes
|
|
.iter()
|
|
.map(|n| markup_value(n, content))
|
|
.collect(),
|
|
),
|
|
);
|
|
value.insert("calibrated", Value::Bool(question.calibrated));
|
|
let mut by_form = Value::dict();
|
|
for (form, p) in &question.by_form {
|
|
by_form.insert(form.clone(), Value::Float(*p));
|
|
}
|
|
value.insert("by-form", by_form);
|
|
value
|
|
}
|
|
|
|
/// Emits a `#let` binding for a slot.
|
|
fn binding(name: &str, value: &Value) -> String {
|
|
format!("#let {name} = {}\n", value.to_typst(0))
|
|
}
|
|
|
|
/// Renders one student's report.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `layout` - the course layout, for the template lookup.
|
|
/// * `meta` - the identity block.
|
|
/// * `diagnostic` - the student's diagnostic.
|
|
/// * `config` - the render config for this variant.
|
|
/// * `explicit` - a template path overriding the lookup.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// The rendered document.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`Error::Io`] when an explicit template cannot be read and
|
|
/// [`Error::Invalid`] when a template's markers are malformed.
|
|
pub fn render_student(
|
|
layout: &Layout,
|
|
meta: &Meta,
|
|
diagnostic: &StudentDiagnostic,
|
|
config: &RenderConfig,
|
|
explicit: Option<&std::path::Path>,
|
|
) -> Result<Document> {
|
|
render(
|
|
layout,
|
|
Variant::StudentReport,
|
|
&meta.assessment_id,
|
|
meta,
|
|
student_value(diagnostic, config),
|
|
config,
|
|
explicit,
|
|
)
|
|
}
|
|
|
|
/// Renders the class report.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `layout` - the course layout, for the template lookup.
|
|
/// * `meta` - the identity block.
|
|
/// * `diagnostic` - the class diagnostic.
|
|
/// * `config` - the render config for this variant.
|
|
/// * `explicit` - a template path overriding the lookup.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// The rendered document.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// As [`render_student`].
|
|
pub fn render_cohort(
|
|
layout: &Layout,
|
|
meta: &Meta,
|
|
diagnostic: &CohortDiagnostic,
|
|
config: &RenderConfig,
|
|
explicit: Option<&std::path::Path>,
|
|
) -> Result<Document> {
|
|
render(
|
|
layout,
|
|
Variant::CohortReport,
|
|
&meta.assessment_id,
|
|
meta,
|
|
cohort_value(diagnostic, config),
|
|
config,
|
|
explicit,
|
|
)
|
|
}
|
|
|
|
/// The shared rendering path.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn render(
|
|
layout: &Layout,
|
|
variant: Variant,
|
|
assessment_id: &str,
|
|
meta: &Meta,
|
|
data: Value,
|
|
config: &RenderConfig,
|
|
explicit: Option<&std::path::Path>,
|
|
) -> Result<Document> {
|
|
let template = template::load(layout, variant, Some(assessment_id), explicit)?;
|
|
|
|
let mut bodies = Vec::new();
|
|
if template.wants(Slot::Meta) {
|
|
bodies.push((
|
|
Slot::Meta,
|
|
binding(&config.meta_binding, &meta.value(config)),
|
|
));
|
|
}
|
|
if template.wants(Slot::Data) {
|
|
bodies.push((Slot::Data, binding(&config.data_binding, &data)));
|
|
}
|
|
|
|
let mut warnings = Vec::new();
|
|
for (slot, body) in &bodies {
|
|
if crate::markup::needs_chem_import(&template.source, body) {
|
|
warnings.push(format!(
|
|
"the `{}` slot carries a chemical formula, but the template {} does not import \
|
|
whalogen, so Typst will stop at `unknown variable: ce`; add `{}`",
|
|
slot.as_str(),
|
|
template.origin,
|
|
crate::markup::CHEM_IMPORT
|
|
));
|
|
}
|
|
}
|
|
if template.is_inert() {
|
|
warnings.push(format!(
|
|
"the template {} declares no coursebank markers, so the report is empty; add `// \
|
|
coursebank:data` where the body belongs",
|
|
template.origin
|
|
));
|
|
} else if !template.wants(Slot::Data) {
|
|
warnings.push(format!(
|
|
"the template {} declares no `data` slot, so it received the metadata but not the \
|
|
report itself",
|
|
template.origin
|
|
));
|
|
}
|
|
if template.wants(Slot::Questions) {
|
|
return Err(Error::Invalid(vec![format!(
|
|
"the template {} declares a `questions` slot, but a diagnostic report carries no \
|
|
questions to fill it with. Remove the marker: a report that reproduces the exam \
|
|
cannot be returned before a makeup is given",
|
|
template.origin
|
|
)]));
|
|
}
|
|
|
|
Ok(Document {
|
|
variant,
|
|
origin: template.origin.clone(),
|
|
slots: template.slots(),
|
|
text: template.render(&bodies),
|
|
warnings,
|
|
})
|
|
}
|
|
|
|
/// The file stem a student's report is written under.
|
|
///
|
|
/// Uses the student key rather than the name: a key is unique, filesystem-safe,
|
|
/// and already a pseudonym when the store is pseudonymized.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `assessment_id` - the assessment id.
|
|
/// * `student_key` - the student key.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// The stem, with no extension.
|
|
pub fn student_stem(assessment_id: &str, student_key: &str) -> String {
|
|
let safe: String = student_key
|
|
.chars()
|
|
.map(|c| {
|
|
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
|
c
|
|
} else {
|
|
'-'
|
|
}
|
|
})
|
|
.collect();
|
|
format!("{assessment_id}-{safe}")
|
|
}
|
|
|
|
/// Summarizes what a set of rendered reports covered, for the command line.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `written` - how many files were written.
|
|
/// * `students` - how many students they cover.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// A sentence.
|
|
pub fn summary(written: usize, students: usize) -> String {
|
|
format!(
|
|
"{written} file(s) for {students} student(s); compile them with `typst compile` or the \
|
|
loop in the guide"
|
|
)
|
|
}
|
|
|
|
/// Tallies what a class diagnostic would tell you to do next.
|
|
///
|
|
/// Kept here rather than in the template so that the command line and the PDF
|
|
/// agree about what counts as a finding.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `diagnostic` - the class diagnostic.
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Short lines, most important first.
|
|
pub fn headline(diagnostic: &CohortDiagnostic) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
out.push(format!(
|
|
"{} student(s), mean {:.0}% (SD {:.1}), median {:.0}%",
|
|
diagnostic.n_students,
|
|
diagnostic.distribution.mean,
|
|
diagnostic.distribution.sd,
|
|
diagnostic.distribution.median
|
|
));
|
|
if !diagnostic.gaps.is_empty() {
|
|
out.push(format!(
|
|
"{} objective(s) the class did not meet; worst is {} at {:.0}%",
|
|
diagnostic.gaps.len(),
|
|
diagnostic.gaps[0].id,
|
|
diagnostic.gaps[0].rate * 100.0
|
|
));
|
|
}
|
|
if !diagnostic.revise.is_empty() {
|
|
let numbers: Vec<String> = diagnostic
|
|
.revise
|
|
.iter()
|
|
.take(6)
|
|
.map(|q| format!("q{}", q.number))
|
|
.collect();
|
|
out.push(format!(
|
|
"{} question(s) to look at before reuse: {}",
|
|
diagnostic.revise.len(),
|
|
numbers.join(", ")
|
|
));
|
|
}
|
|
if diagnostic.forms.len() > 1 {
|
|
let spread = diagnostic
|
|
.forms
|
|
.iter()
|
|
.map(|f| f.mean)
|
|
.fold(f64::NEG_INFINITY, f64::max)
|
|
- diagnostic
|
|
.forms
|
|
.iter()
|
|
.map(|f| f.mean)
|
|
.fold(f64::INFINITY, f64::min);
|
|
out.push(format!(
|
|
"{} forms, {:.0} points apart at the mean",
|
|
diagnostic.forms.len(),
|
|
spread
|
|
));
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::diagnostic::{Distribution, ReliabilityRow};
|
|
|
|
fn empty_cohort() -> CohortDiagnostic {
|
|
CohortDiagnostic {
|
|
n_students: 24,
|
|
n_items: 36,
|
|
distribution: Distribution {
|
|
mean: 72.5,
|
|
median: 74.0,
|
|
sd: 12.0,
|
|
min: 41.0,
|
|
max: 97.0,
|
|
bins: vec![Bin {
|
|
low: 70,
|
|
high: 80,
|
|
count: 9,
|
|
}],
|
|
},
|
|
reliability: ReliabilityRow {
|
|
alpha: Some(0.71),
|
|
sem: Some(2.1),
|
|
mean_p: 0.72,
|
|
mean_point_biserial: Some(0.24),
|
|
interpretation: "acceptable for a classroom exam".into(),
|
|
},
|
|
levels: Vec::new(),
|
|
objectives: Vec::new(),
|
|
gaps: Vec::new(),
|
|
grades: Vec::new(),
|
|
lectures: Vec::new(),
|
|
dropped_questions: Vec::new(),
|
|
questions: Vec::new(),
|
|
triage: crate::diagnostic::Triage::default(),
|
|
predictions: crate::diagnostic::PredictionSummary::default(),
|
|
revise: Vec::new(),
|
|
forms: Vec::new(),
|
|
blueprint: Vec::new(),
|
|
patterns: Vec::new(),
|
|
warnings: Vec::new(),
|
|
dropped_detail: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn report_markup_takes_the_same_path_as_a_paper() {
|
|
for source in [
|
|
"$\\ce{H2O <=> H+ + OH-}$",
|
|
"the backbone $\\ce{-C=O}$ group",
|
|
"$K_w = [\\text{H}^+][\\text{OH}^-]$",
|
|
"see @fig:x where x < y",
|
|
"costs \\$5, and just $5",
|
|
] {
|
|
let expected = crate::markup::to_typst(source);
|
|
assert_eq!(
|
|
markup_value(source, true).to_typst(0),
|
|
format!("[{expected}]"),
|
|
"content mode diverged on {source:?}"
|
|
);
|
|
assert_eq!(
|
|
markup_value(source, false).to_typst(0),
|
|
Value::str(expected).to_typst(0),
|
|
"string mode diverged on {source:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_report_carrying_chemistry_names_a_template_missing_the_import() {
|
|
let body = "#let cb-data = (stem: [#ce(\"H2O\")])";
|
|
assert!(crate::markup::needs_chem_import(
|
|
"#import \"@preview/mitex:0.2.7\": mi\n// coursebank:data\n",
|
|
body
|
|
));
|
|
assert!(!crate::markup::needs_chem_import(
|
|
crate::markup::CHEM_IMPORT,
|
|
body
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn the_headline_leads_with_the_distribution() {
|
|
let lines = headline(&empty_cohort());
|
|
assert!(lines[0].contains("24 student(s)"), "{lines:?}");
|
|
assert!(
|
|
lines[0].contains("73%") || lines[0].contains("72%"),
|
|
"{lines:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_student_stem_is_filesystem_safe() {
|
|
assert_eq!(student_stem("e1", "s-9f8e7d"), "e1-s-9f8e7d");
|
|
assert_eq!(student_stem("e1", "ada@x.edu"), "e1-ada-x-edu");
|
|
}
|
|
|
|
#[test]
|
|
fn the_cohort_value_carries_no_question_text() {
|
|
let config = RenderConfig::for_variant(Variant::CohortReport);
|
|
let text = cohort_value(&empty_cohort(), &config).to_typst(0);
|
|
assert!(text.contains("reliability"));
|
|
assert!(!text.contains("stem"));
|
|
}
|
|
}
|