//! Turning responses into something you can say to a student. //! //! Item analysis tells you about items. This module tells you about people: which //! objectives a student has actually met, which ones they are close on, and what //! specifically to do next. //! //! # On declaring mastery from three questions //! //! The honest answer is that you often cannot. Two items on an objective give a //! proportion with an enormous confidence interval — two out of two correct is //! consistent with a true rate anywhere from about 0.55 upward. So this module //! does three things instead of pretending otherwise. //! //! It refuses to classify at all below `min_items_for_mastery`, reporting "not //! enough evidence", which is a finding about your blueprint rather than about the //! student. It reports the Wilson score interval alongside every rate, because //! Wilson behaves sensibly at the boundaries where the normal approximation //! produces intervals extending past 1.0. And it separates the *classification* //! (which uses the observed rate, so it is usable) from the *confidence* (which //! uses the interval, so it is honest). A student can be "meeting" an objective //! provisionally, and the report says so. //! //! # Comparison to the cohort //! //! Per-level performance is reported against the class rather than in absolute //! terms, because "you got 60% of the Analyze items" means nothing to a student //! without knowing that the class average was 55%. The comparison is descriptive, //! not a curve. use std::collections::{BTreeMap, BTreeSet}; use crate::course::{CourseFile, Policy}; use crate::responses::{Response, ResponseSet}; use crate::rng::Rng; use crate::taxonomy::Level; /// How well a student has met one objective. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mastery { /// Met the threshold. Meeting, /// Partway there. Developing, /// Not yet. NotYet, /// Too few items on this objective to say anything. This is a gap in the /// assessment, not a judgment about the student. NotEnoughEvidence, } impl Mastery { /// A label for reports. pub fn label(self) -> &'static str { match self { Mastery::Meeting => "meeting", Mastery::Developing => "developing", Mastery::NotYet => "not yet", Mastery::NotEnoughEvidence => "not enough evidence", } } /// A short symbol for compact tables. pub fn symbol(self) -> &'static str { match self { Mastery::Meeting => "✓", Mastery::Developing => "~", Mastery::NotYet => "✗", Mastery::NotEnoughEvidence => "?", } } } /// One student's standing on one objective. #[derive(Debug, Clone)] pub struct ObjectiveMastery { /// The objective id. pub objective: String, /// The objective text, for reports. pub text: String, /// How many items on this objective the student saw. pub n_items: usize, /// How many they got right, counting partial credit. pub credit: f64, /// Observed rate, `credit / n_items`. pub rate: f64, /// Lower end of the Wilson interval. pub wilson_lower: f64, /// Upper end of the Wilson interval. pub wilson_upper: f64, /// The class's rate on the same objective. pub cohort_rate: f64, /// The classification. pub status: Mastery, /// Whether the interval, not just the point estimate, clears the threshold. pub confident: bool, /// Which levels the objective was assessed at, since meeting an objective at /// Remember is a different claim from meeting it at Analyze. pub levels: Vec, } /// One student's performance at one cognitive level. #[derive(Debug, Clone)] pub struct LevelProfile { /// The level. pub level: Level, /// How many items at this level. pub n_items: usize, /// The student's rate. pub rate: f64, /// The class's rate. pub cohort_rate: f64, /// Difference from the class, in class standard deviations. `None` when the /// class had no spread at this level. pub z: Option, } impl LevelProfile { /// A plain-language comparison to the class. pub fn comparison(&self) -> &'static str { match self.z { Some(z) if z >= 1.0 => "well above the class", Some(z) if z >= 0.4 => "above the class", Some(z) if z > -0.4 => "about the same as the class", Some(z) if z > -1.0 => "below the class", Some(_) => "well below the class", None => "the class did not vary here", } } } /// An item the student got wrong, with what to do about it. #[derive(Debug, Clone)] pub struct MissedItem { /// The question number. pub number: u32, /// The item's global id. pub item_ref: Option, /// What the student chose. pub selected: Vec, /// Credit earned, since a partially credited response is not a clean miss. pub credit: f64, /// The level. pub level: Option, /// The objectives involved. pub learning_objectives: Vec, /// The misconception the chosen distractor was written to detect. pub misconception: Option, /// Feedback written for a student who chose that option. pub feedback: Option, /// Where to go back to: lecture titles and slide numbers. pub study: Vec, } /// Everything needed to write one student's report. #[derive(Debug, Clone)] pub struct StudentSummary { /// The grouping key. pub student_key: String, /// The name, when not pseudonymized. pub name: Option, /// The student id, when not pseudonymized. pub sid: Option, /// Points earned on scored items. pub points: f64, /// Points available on scored items. pub points_possible: f64, /// Percentage on scored items. pub percent: f64, /// Bonus points earned. pub bonus_points: f64, /// Items answered correctly. pub correct: usize, /// Items administered. pub n_items: usize, /// Where the score falls relative to the class, as a coarse band. pub band: String, /// IRT ability, when an IRT fit was supplied. pub theta: Option, /// Standard error of the ability estimate. pub theta_se: Option, /// Per-objective standing, in the course's objective order. pub objectives: Vec, /// Per-level standing. pub levels: Vec, /// Objectives the student is clearly meeting. pub strengths: Vec, /// Objectives to work on, worst first. pub focus: Vec, /// Missed items with targeted guidance. pub missed: Vec, } impl StudentSummary { /// The display name, falling back to the key. pub fn display_name(&self) -> String { self.name .clone() .unwrap_or_else(|| self.student_key.clone()) } } /// Class-level context. #[derive(Debug, Clone)] pub struct Cohort { /// Per-student summaries, sorted by key. pub students: Vec, /// Class rate per objective. pub objective_rates: BTreeMap, /// Class rate per level. pub level_rates: BTreeMap, /// Mean percentage. pub mean_percent: f64, /// Standard deviation of percentage. pub sd_percent: f64, /// Objectives the class as a whole did not meet, worst first. This is the /// list that should change what you reteach. pub class_gaps: Vec<(String, f64)>, /// Optional grouping of students by response profile. pub archetypes: Vec, } /// A cluster of students with a similar profile across levels. #[derive(Debug, Clone)] pub struct Archetype { /// A label describing the pattern. pub label: String, /// The student keys in this cluster. pub members: Vec, /// Mean rate at each level for this cluster. pub level_means: BTreeMap, } /// The Wilson score interval for a binomial proportion. /// /// Preferred over the normal approximation because it stays inside `[0, 1]` and /// behaves at the boundaries, which is exactly where classroom data lives: a /// student who got three out of three needs an interval, and the textbook formula /// gives width zero there. /// /// # Arguments /// /// * `successes` - the number of successes, which may be fractional when partial /// credit is involved. /// * `n` - the number of trials. /// * `z` - the standard normal quantile; 1.96 for a two-sided 95% interval. /// /// # Returns /// /// The lower and upper bounds, or `(0.0, 1.0)` when there are no trials. pub fn wilson(successes: f64, n: usize, z: f64) -> (f64, f64) { if n == 0 { return (0.0, 1.0); } let n = n as f64; let p = (successes / n).clamp(0.0, 1.0); let z2 = z * z; let denominator = 1.0 + z2 / n; let center = p + z2 / (2.0 * n); let spread = z * ((p * (1.0 - p) / n) + z2 / (4.0 * n * n)).sqrt(); ( ((center - spread) / denominator).clamp(0.0, 1.0), ((center + spread) / denominator).clamp(0.0, 1.0), ) } /// Builds per-student summaries for one administration. /// /// # Arguments /// /// * `set` - the responses, already enriched with item metadata. /// * `course` - the course, for objective text, order, and policy. /// * `catalog` - the loaded course, for misconception feedback on missed items. /// * `fit` - an optional IRT fit, whose abilities are attached when present. /// /// # Returns /// /// The cohort. pub fn summarize( set: &ResponseSet, course: &CourseFile, catalog: Option<&crate::catalog::Catalog>, fit: Option<&crate::irt::Fit>, ) -> Cohort { let policy = &course.policy; let students = set.students(); // Class rates first: every student's report is relative to these. let objective_rates = rates_by_objective(&set.rows.iter().collect::>()); let level_rates = rates_by_level(&set.rows.iter().collect::>()); // Per-level spread across students, for the z comparisons. let mut level_values: BTreeMap> = BTreeMap::new(); for key in &students { let rows = set.for_student(key); for (level, rate) in rates_by_level(&rows) { level_values.entry(level).or_default().push(rate); } } let level_sd: BTreeMap = level_values .iter() .map(|(level, values)| (*level, sd(values))) .collect(); let percents: Vec = students .iter() .map(|key| { let earned = set.scored_total(key); let possible = set.points_available(); if possible > 0.0 { 100.0 * earned / possible } else { 0.0 } }) .collect(); let mean_percent = mean(&percents); let sd_percent = sd(&percents); let ability = fit.map(|f| f.ability_map()).unwrap_or_default(); let ability_se: BTreeMap = fit .map(|f| { f.abilities .iter() .map(|a| (a.student_key.clone(), a.se)) .collect() }) .unwrap_or_default(); let order = course.objectives_in_order(); let mut summaries = Vec::with_capacity(students.len()); for (index, key) in students.iter().enumerate() { let rows = set.for_student(key); let points = set.scored_total(key); let possible = set.points_available(); let percent = percents[index]; let correct = rows .iter() .filter(|r| r.counts() && r.correct == Some(true)) .count(); let n_items = rows.iter().filter(|r| r.counts()).count(); // Objectives, in the course's declared order so reports read the way the // course is taught rather than alphabetically. let per_objective = rates_by_objective(&rows); let counts = counts_by_objective(&rows); let mut objectives = Vec::new(); let mut seen: BTreeSet<&String> = BTreeSet::new(); for id in order.iter().chain(per_objective.keys()) { if !seen.insert(id) { continue; } let Some((n, credit)) = counts.get(id).copied() else { continue; }; objectives.push(objective_mastery( id, course, n, credit, objective_rates.get(id).copied().unwrap_or(0.0), &rows, policy, )); } // Levels. let student_levels = rates_by_level(&rows); let level_counts = counts_by_level(&rows); let levels: Vec = Level::ALL .iter() .filter_map(|level| { let (n, _) = level_counts.get(level).copied()?; if n == 0 { return None; } let rate = student_levels.get(level).copied().unwrap_or(0.0); let cohort_rate = level_rates.get(level).copied().unwrap_or(0.0); let spread = level_sd.get(level).copied().unwrap_or(0.0); Some(LevelProfile { level: *level, n_items: n, rate, cohort_rate, z: if spread > 1e-9 { Some((rate - cohort_rate) / spread) } else { None }, }) }) .collect(); // Strengths and focus areas. Strengths need confidence, focus areas do // not: telling a student to review something they may already know costs // them an hour, while telling them they have mastered something they have // not costs them the next exam. let strengths: Vec = objectives .iter() .filter(|o| o.status == Mastery::Meeting && o.confident) .map(|o| o.objective.clone()) .collect(); let mut focus_pairs: Vec<(&ObjectiveMastery, f64)> = objectives .iter() .filter(|o| matches!(o.status, Mastery::NotYet | Mastery::Developing)) .map(|o| (o, o.rate)) .collect(); focus_pairs.sort_by(|a, b| { a.1.partial_cmp(&b.1) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| a.0.objective.cmp(&b.0.objective)) }); let focus: Vec = focus_pairs .iter() .map(|(o, _)| o.objective.clone()) .collect(); let missed = missed_items(&rows, catalog, course); summaries.push(StudentSummary { student_key: key.clone(), name: rows.first().and_then(|r| r.name.clone()), sid: rows.first().and_then(|r| r.sid.clone()), points, points_possible: possible, percent, bonus_points: set.bonus_total(key), correct, n_items, band: band_for(percent, &percents), theta: ability.get(key).copied(), theta_se: ability_se.get(key).copied(), objectives, levels, strengths, focus, missed, }); } // Class gaps: objectives where the whole class fell short. These are the ones // to reteach rather than to send individual students away to review. let mut class_gaps: Vec<(String, f64)> = objective_rates .iter() .filter(|(_, rate)| **rate < policy.mastery_threshold) .map(|(id, rate)| (id.clone(), *rate)) .collect(); class_gaps.sort_by(|a, b| { a.1.partial_cmp(&b.1) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| a.0.cmp(&b.0)) }); let archetypes = cluster(&summaries, 3); Cohort { students: summaries, objective_rates, level_rates, mean_percent, sd_percent, class_gaps, archetypes, } } /// Builds one objective's mastery record. /// /// # Arguments /// /// * `id` - the objective id. /// * `course` - the course, for text and policy. /// * `n` - items on this objective. /// * `credit` - total credit earned. /// * `cohort_rate` - the class rate. /// * `rows` - the student's responses, for the level list. /// * `policy` - the course policy. /// /// # Returns /// /// The record. fn objective_mastery( id: &str, course: &CourseFile, n: usize, credit: f64, cohort_rate: f64, rows: &[&Response], policy: &Policy, ) -> ObjectiveMastery { let rate = if n > 0 { credit / n as f64 } else { 0.0 }; let (lower, upper) = wilson(credit, n, 1.96); let status = if n < policy.min_items_for_mastery.max(1) { Mastery::NotEnoughEvidence } else if rate >= policy.mastery_threshold { Mastery::Meeting } else if rate >= policy.mastery_threshold * 0.6 { Mastery::Developing } else { Mastery::NotYet }; let levels: Vec = rows .iter() .filter(|r| r.learning_objectives.iter().any(|o| o == id)) .filter_map(|r| r.level) .collect::>() .into_iter() .collect(); ObjectiveMastery { objective: id.to_string(), text: course.objective_text(id), n_items: n, credit, rate, wilson_lower: lower, wilson_upper: upper, cohort_rate, status, confident: lower >= policy.mastery_threshold, levels, } } /// Collects missed items with targeted guidance. /// /// The guidance comes from the item's own authoring: the `misconception` recorded /// on the distractor the student actually chose, and the lecture and slides the /// item was written from. This is why authoring distractors deliberately pays off /// twice — once when writing the item, and again in every report afterward. /// /// # Arguments /// /// * `rows` - the student's responses. /// * `catalog` - the loaded course. /// * `course` - the course, for lecture titles. /// /// # Returns /// /// The missed items, in question order. fn missed_items( rows: &[&Response], catalog: Option<&crate::catalog::Catalog>, course: &CourseFile, ) -> Vec { let mut out = Vec::new(); for r in rows { if !r.counts() || r.credit >= 0.999 { continue; } let mut misconception = None; let mut feedback = None; let mut study = Vec::new(); if let (Some(cat), Some(uid)) = (catalog, r.item_ref.as_deref()) { if let Some(entry) = cat.get(uid) { // Feedback for the specific option chosen, which is the whole // point of recording per-distractor misconceptions. if let Some(letter) = r.selected.first() { if let Some(choice) = entry.item.option(letter) { misconception = choice.misconception.clone(); feedback = choice.student_text().map(|s| s.to_string()); } } for source in &entry.item.sources { let title = course .lectures .get(&source.lecture) .map(|l| l.title.clone()) .unwrap_or_else(|| source.lecture.clone()); if source.slides.is_empty() { study.push(title); } else { let slides: Vec = source.slides.iter().map(|s| s.to_string()).collect(); study.push(format!("{title}, slides {}", slides.join(", "))); } for reading in &source.readings { study.push(reading.clone()); } } } } out.push(MissedItem { number: r.item_number, item_ref: r.item_ref.clone(), selected: r.selected.clone(), credit: r.credit, level: r.level, learning_objectives: r.learning_objectives.clone(), misconception, feedback, study, }); } out } /// Credit rate per objective over a set of responses. /// /// # Arguments /// /// * `rows` - the responses. /// /// # Returns /// /// The rate for each objective mentioned. pub fn rates_by_objective(rows: &[&Response]) -> BTreeMap { counts_by_objective(rows) .into_iter() .map(|(id, (n, credit))| { let rate = if n > 0 { credit / n as f64 } else { 0.0 }; (id, rate) }) .collect() } /// Item counts and credit per objective. /// /// An item tagged with two objectives counts toward both. That double counting is /// intentional: the question "how is this student doing on kinetics" should use /// every item that measured kinetics. /// /// # Arguments /// /// * `rows` - the responses. /// /// # Returns /// /// `(item count, total credit)` per objective. pub fn counts_by_objective(rows: &[&Response]) -> BTreeMap { let mut out: BTreeMap = BTreeMap::new(); for r in rows { if !r.counts() { continue; } for objective in &r.learning_objectives { let e = out.entry(objective.clone()).or_insert((0, 0.0)); e.0 += 1; e.1 += r.credit.clamp(0.0, 1.0); } } out } /// Credit rate per level. /// /// # Arguments /// /// * `rows` - the responses. /// /// # Returns /// /// The rate for each level present. pub fn rates_by_level(rows: &[&Response]) -> BTreeMap { counts_by_level(rows) .into_iter() .map(|(level, (n, credit))| { let rate = if n > 0 { credit / n as f64 } else { 0.0 }; (level, rate) }) .collect() } /// Item counts and credit per level. /// /// # Arguments /// /// * `rows` - the responses. /// /// # Returns /// /// `(item count, total credit)` per level. pub fn counts_by_level(rows: &[&Response]) -> BTreeMap { let mut out: BTreeMap = BTreeMap::new(); for r in rows { if !r.counts() { continue; } if let Some(level) = r.level { let e = out.entry(level).or_insert((0, 0.0)); e.0 += 1; e.1 += r.credit.clamp(0.0, 1.0); } } out } /// A coarse band for a score within a class. /// /// Quartile bands rather than an exact percentile, because a percentile computed /// from twenty-four students implies a precision it does not have, and because /// telling a student they are "37th percentile" invites comparison in a way that /// "middle half of the class" does not. /// /// # Arguments /// /// * `percent` - the student's percentage. /// * `all` - every student's percentage. /// /// # Returns /// /// The band label. fn band_for(percent: f64, all: &[f64]) -> String { if all.len() < 4 { return "the class is too small to place this meaningfully".to_string(); } let mut sorted = all.to_vec(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let below = sorted.iter().filter(|x| **x < percent).count() as f64; let fraction = below / all.len() as f64; if fraction >= 0.75 { "top quarter of the class".to_string() } else if fraction >= 0.5 { "upper middle of the class".to_string() } else if fraction >= 0.25 { "lower middle of the class".to_string() } else { "bottom quarter of the class".to_string() } } /// Groups students by their profile across levels. /// /// This is descriptive, not diagnostic. It answers "are there recognizable /// patterns in how this class is struggling" — for instance a group that handles /// recall fine and falls apart on application, which calls for different /// instruction than a group that is uniformly behind. /// /// k-means with a seeded, deterministic initialization, so the same data always /// produces the same groups. /// /// # Arguments /// /// * `students` - the summaries. /// * `k` - how many clusters to look for. /// /// # Returns /// /// The clusters, largest first. Empty when there are too few students to bother. pub fn cluster(students: &[StudentSummary], k: usize) -> Vec { // Below about three students per cluster the groups are noise. if students.len() < k * 3 || k == 0 { return Vec::new(); } // Feature vector: rate at each level that anyone was assessed on. let levels: Vec = students .iter() .flat_map(|s| s.levels.iter().map(|l| l.level)) .collect::>() .into_iter() .collect(); if levels.len() < 2 { return Vec::new(); } let points: Vec> = students .iter() .map(|s| { levels .iter() .map(|level| { s.levels .iter() .find(|l| l.level == *level) .map(|l| l.rate) .unwrap_or(0.0) }) .collect() }) .collect(); // Standardize each dimension so a level everyone did well on does not // dominate the distance. let mut standardized = points.clone(); for d in 0..levels.len() { let column: Vec = points.iter().map(|p| p[d]).collect(); let m = mean(&column); let s = sd(&column); for (i, point) in standardized.iter_mut().enumerate() { point[d] = if s > 1e-9 { (points[i][d] - m) / s } else { 0.0 }; } } // Seeded k-means++ initialization. let mut rng = Rng::from_label("coursebank/archetypes"); let mut centers: Vec> = vec![standardized[rng.below(standardized.len() as u64) as usize].clone()]; while centers.len() < k { let distances: Vec = standardized .iter() .map(|p| { centers .iter() .map(|c| squared_distance(p, c)) .fold(f64::INFINITY, f64::min) }) .collect(); let total: f64 = distances.iter().sum(); if total <= 0.0 { break; } let mut target = rng.unit() * total; let mut chosen = standardized.len() - 1; for (i, d) in distances.iter().enumerate() { target -= d; if target <= 0.0 { chosen = i; break; } } centers.push(standardized[chosen].clone()); } let mut assignment = vec![0usize; standardized.len()]; for _ in 0..50 { let mut changed = false; for (i, p) in standardized.iter().enumerate() { let mut best = (0usize, f64::INFINITY); for (c, center) in centers.iter().enumerate() { let d = squared_distance(p, center); if d < best.1 { best = (c, d); } } if assignment[i] != best.0 { assignment[i] = best.0; changed = true; } } for (c, center) in centers.iter_mut().enumerate() { let members: Vec<&Vec> = standardized .iter() .enumerate() .filter(|(i, _)| assignment[*i] == c) .map(|(_, p)| p) .collect(); if members.is_empty() { continue; } for d in 0..levels.len() { center[d] = members.iter().map(|p| p[d]).sum::() / members.len() as f64; } } if !changed { break; } } let mut out = Vec::new(); for c in 0..centers.len() { let members: Vec = students .iter() .enumerate() .filter(|(i, _)| assignment[*i] == c) .map(|(_, s)| s.student_key.clone()) .collect(); if members.is_empty() { continue; } let mut level_means = BTreeMap::new(); for (d, level) in levels.iter().enumerate() { let values: Vec = students .iter() .enumerate() .filter(|(i, _)| assignment[*i] == c) .map(|(i, _)| points[i][d]) .collect(); level_means.insert(*level, mean(&values)); } out.push(Archetype { label: label_for(&level_means), members, level_means, }); } out.sort_by(|a, b| b.members.len().cmp(&a.members.len())); out } /// Names a cluster from its level profile. /// /// # Arguments /// /// * `means` - mean rate at each level. /// /// # Returns /// /// A descriptive label. fn label_for(means: &BTreeMap) -> String { let values: Vec = means.values().copied().collect(); let overall = mean(&values); // Is the profile flat, or does it fall off with cognitive demand? let low: Vec = means .iter() .filter(|(l, _)| l.code() <= 2) .map(|(_, v)| *v) .collect(); let high: Vec = means .iter() .filter(|(l, _)| l.code() >= 3) .map(|(_, v)| *v) .collect(); if !low.is_empty() && !high.is_empty() { let drop = mean(&low) - mean(&high); if drop > 0.25 { return "knows the material, struggles to apply it".to_string(); } if drop < -0.15 { return "reasons well, gaps in recall".to_string(); } } if overall >= 0.85 { "consistently strong".to_string() } else if overall >= 0.65 { "solid with scattered gaps".to_string() } else { "behind across the board".to_string() } } /// Squared Euclidean distance. fn squared_distance(a: &[f64], b: &[f64]) -> f64 { a.iter().zip(b.iter()).map(|(x, y)| (x - y) * (x - y)).sum() } /// The arithmetic mean, zero for an empty slice. fn mean(v: &[f64]) -> f64 { if v.is_empty() { 0.0 } else { v.iter().sum::() / v.len() as f64 } } /// The population standard deviation. fn sd(v: &[f64]) -> f64 { if v.len() < 2 { return 0.0; } let m = mean(v); (v.iter().map(|x| (x - m) * (x - m)).sum::() / v.len() as f64).sqrt() } #[cfg(test)] mod tests { use super::*; #[test] fn wilson_stays_inside_zero_and_one() { // Three out of three: the naive interval has zero width, Wilson does not. let (lo, hi) = wilson(3.0, 3, 1.96); assert!(lo > 0.0 && lo < 1.0, "lower bound {lo}"); assert_eq!(hi, 1.0); assert!(lo < 0.5, "three items cannot establish a high rate: {lo}"); // Zero out of four. let (lo, hi) = wilson(0.0, 4, 1.96); assert_eq!(lo, 0.0); assert!(hi > 0.0 && hi < 1.0); // No data at all. assert_eq!(wilson(0.0, 0, 1.96), (0.0, 1.0)); } #[test] fn wilson_narrows_as_n_grows() { let (lo_small, hi_small) = wilson(8.0, 10, 1.96); let (lo_big, hi_big) = wilson(80.0, 100, 1.96); assert!( (hi_big - lo_big) < (hi_small - lo_small), "more data must give a tighter interval" ); } #[test] fn two_items_never_claim_confident_mastery() { // The classification may say "meeting", but confidence must not, because // two items cannot establish a rate of 0.75. let (lower, _) = wilson(2.0, 2, 1.96); assert!(lower < 0.75, "got {lower}"); } #[test] fn bands_describe_position_coarsely() { let all = vec![50.0, 60.0, 70.0, 80.0, 90.0, 95.0, 40.0, 30.0]; assert!(band_for(95.0, &all).contains("top")); assert!(band_for(30.0, &all).contains("bottom")); // Too few students to place anyone. assert!(band_for(50.0, &[50.0, 60.0]).contains("too small")); } #[test] fn objective_counts_credit_every_tagged_item() { let rows = vec![ make("s1", 1, 1.0, &["lo-a", "lo-b"], Some(Level::Remember)), make("s1", 2, 0.0, &["lo-a"], Some(Level::Apply)), ]; let refs: Vec<&Response> = rows.iter().collect(); let counts = counts_by_objective(&refs); // lo-a saw both items; lo-b only the first. assert_eq!(counts["lo-a"], (2, 1.0)); assert_eq!(counts["lo-b"], (1, 1.0)); let rates = rates_by_objective(&refs); assert_eq!(rates["lo-a"], 0.5); assert_eq!(rates["lo-b"], 1.0); } #[test] fn level_rates_ignore_untagged_items() { let rows = vec![ make("s1", 1, 1.0, &[], Some(Level::Remember)), make("s1", 2, 0.0, &[], None), ]; let refs: Vec<&Response> = rows.iter().collect(); let counts = counts_by_level(&refs); assert_eq!(counts.len(), 1); assert_eq!(counts[&Level::Remember], (1, 1.0)); } #[test] fn mastery_labels_are_stable() { assert_eq!(Mastery::Meeting.label(), "meeting"); assert_eq!(Mastery::NotEnoughEvidence.label(), "not enough evidence"); } #[test] fn clustering_needs_enough_students() { assert!(cluster(&[], 3).is_empty()); let few: Vec = (0..4).map(|i| summary(&format!("s{i}"))).collect(); assert!(cluster(&few, 3).is_empty(), "four students, three clusters"); } #[test] fn clustering_is_deterministic_and_separates_profiles() { // Half the class is strong on recall and weak on application; half is // uniformly strong. Those are different problems. let mut students = Vec::new(); for i in 0..12 { let mut s = summary(&format!("s{i:02}")); let (recall, apply) = if i < 6 { (0.95, 0.35) } else { (0.9, 0.85) }; s.levels = vec![ profile(Level::Remember, recall), profile(Level::Apply, apply), ]; students.push(s); } let first = cluster(&students, 2); let second = cluster(&students, 2); assert_eq!(first.len(), 2); assert_eq!( first.iter().map(|a| a.members.clone()).collect::>(), second.iter().map(|a| a.members.clone()).collect::>(), "clustering must be reproducible" ); // The two groups must not be mixed together. let sizes: Vec = first.iter().map(|a| a.members.len()).collect(); assert_eq!(sizes, vec![6, 6], "got {sizes:?}"); assert!(first.iter().any(|a| a.label.contains("struggles to apply"))); } fn make( student: &str, number: u32, credit: f64, objectives: &[&str], level: Option, ) -> Response { Response { administration_id: "C/T/a".into(), course: "C".into(), term: "T".into(), assessment_id: "a".into(), date: None, form: None, student_key: student.into(), sid: None, name: None, email: None, section: None, item_number: number, item_ref: None, item_version: None, selected: vec!["A".into()], eliminated: vec![], correct: Some(credit >= 0.999), credit, points_possible: 1.0, score: credit, response_time_seconds: None, level, learning_objectives: objectives.iter().map(|s| s.to_string()).collect(), topics: vec![], bonus: false, dropped: false, } } fn summary(key: &str) -> StudentSummary { StudentSummary { student_key: key.to_string(), name: None, sid: None, points: 0.0, points_possible: 0.0, percent: 0.0, bonus_points: 0.0, correct: 0, n_items: 0, band: String::new(), theta: None, theta_se: None, objectives: Vec::new(), levels: Vec::new(), strengths: Vec::new(), focus: Vec::new(), missed: Vec::new(), } } fn profile(level: Level, rate: f64) -> LevelProfile { LevelProfile { level, n_items: 4, rate, cohort_rate: rate, z: None, } } }