1085 lines
37 KiB
Rust
1085 lines
37 KiB
Rust
// 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<String>,
|
|
}
|
|
|
|
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<f64> = summary
|
|
.levels
|
|
.iter()
|
|
.filter(|l| l.level.code() <= 2)
|
|
.map(|l| l.rate)
|
|
.collect();
|
|
let applied: Vec<f64> = 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<String> = 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<String> = 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::<Vec<_>>()
|
|
.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::<Vec<_>>()
|
|
.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<String> = 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::<Vec<_>>()
|
|
.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::<f64>() / 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!("</{tag}>\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("<hr>\n");
|
|
continue;
|
|
}
|
|
|
|
if let Some(rest) = trimmed.strip_prefix("> ") {
|
|
close_list(&mut body, &mut list_kind);
|
|
body.push_str(&format!("<blockquote>{}</blockquote>\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!("<h{hashes}>{}</h{hashes}>\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("<table>\n<thead><tr>");
|
|
for cell in split_row(trimmed) {
|
|
body.push_str(&format!("<th>{}</th>", inline(&cell)));
|
|
}
|
|
body.push_str("</tr></thead>\n<tbody>\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("<tr>");
|
|
for cell in split_row(row.trim()) {
|
|
body.push_str(&format!("<td>{}</td>", inline(&cell)));
|
|
}
|
|
body.push_str("</tr>\n");
|
|
}
|
|
body.push_str("</tbody>\n</table>\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("<ul>\n");
|
|
list_kind = Some("ul");
|
|
}
|
|
body.push_str(&format!("<li>{}</li>\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("<ol>\n");
|
|
list_kind = Some("ol");
|
|
}
|
|
body.push_str(&format!("<li>{}</li>\n", inline(rest)));
|
|
continue;
|
|
}
|
|
}
|
|
|
|
close_list(&mut body, &mut list_kind);
|
|
body.push_str(&format!("<p>{}</p>\n", inline(trimmed)));
|
|
}
|
|
close_list(&mut body, &mut list_kind);
|
|
|
|
format!(
|
|
"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\
|
|
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
|
|
<title>{}</title>\n<style>\n{}\n</style>\n</head>\n<body>\n{}</body>\n</html>\n",
|
|
crate::markup::escape_html(title),
|
|
STYLE,
|
|
body
|
|
)
|
|
}
|
|
|
|
/// Splits a Markdown table row into cells.
|
|
fn split_row(row: &str) -> Vec<String> {
|
|
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, "**", "<strong>", "</strong>");
|
|
out = wrap(&out, "`", "<code>", "</code>");
|
|
out = wrap(&out, "*", "<em>", "</em>");
|
|
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<Vec<std::path::PathBuf>> {
|
|
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("<h1>Title</h1>"));
|
|
assert!(html.contains("<p>Some text.</p>"));
|
|
assert!(html.starts_with("<!DOCTYPE html>"));
|
|
assert!(html.contains("<title>T</title>"));
|
|
}
|
|
|
|
#[test]
|
|
fn html_conversion_builds_tables() {
|
|
let md = "| A | B |\n|:--|--:|\n| 1 | 2 |\n";
|
|
let html = to_html(md, "T");
|
|
assert!(html.contains("<table>"));
|
|
assert!(html.contains("<th>A</th>"));
|
|
assert!(html.contains("<td>2</td>"));
|
|
assert!(html.contains("</tbody>"));
|
|
}
|
|
|
|
#[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("<ul>"));
|
|
assert!(html.contains("<li>one</li>"));
|
|
assert!(html.contains("<ol>"));
|
|
assert!(html.contains("<li>first</li>"));
|
|
// Lists must be closed, not left dangling.
|
|
assert_eq!(html.matches("<ul>").count(), html.matches("</ul>").count());
|
|
assert_eq!(html.matches("<ol>").count(), html.matches("</ol>").count());
|
|
}
|
|
|
|
#[test]
|
|
fn html_conversion_escapes_before_emphasis() {
|
|
let html = to_html("**bold** and <script>alert(1)</script>\n", "T");
|
|
assert!(html.contains("<strong>bold</strong>"));
|
|
assert!(!html.contains("<script>"));
|
|
assert!(html.contains("<script>"));
|
|
}
|
|
|
|
#[test]
|
|
fn html_conversion_handles_rules_and_quotes() {
|
|
let html = to_html("> careful\n\n---\n\ntext\n", "T");
|
|
assert!(html.contains("<blockquote>careful</blockquote>"));
|
|
assert!(html.contains("<hr>"));
|
|
}
|
|
|
|
#[test]
|
|
fn unpaired_emphasis_is_left_alone() {
|
|
let html = to_html("2 * 3 = 6\n", "T");
|
|
assert!(html.contains("2 * 3 = 6"), "{html}");
|
|
}
|
|
}
|