Skip to main content

headless_lms_models/library/credit_registration/
grade_mapping.rs

1//! Our grade in the study registry's terms. A scale or grade the registry does not know is rejected
2//! at request level, taking the whole batch of twenty-five with it, so every pair that reaches a
3//! batch has been through [`map_grade`] or [`is_known_grade`].
4
5use crate::credit_registrations::CreditRegistrationErrorCode;
6
7/// TODO: Suotar has not confirmed the spelling. Both are accepted on the way in; this is the one we
8/// send.
9pub const PASS_FAIL_GRADE_SCALE_ID: &str = "sis-hyl-hyv";
10/// The other accepted spelling of the same scale, which our own legacy pull path sends.
11pub const PASS_FAIL_GRADE_SCALE_ID_ALT: &str = "sis-hyv-hyl";
12pub const NUMERIC_GRADE_SCALE_ID: &str = "sis-0-5";
13
14pub const PASS_GRADE_ID: &str = "1";
15pub const FAIL_GRADE_ID: &str = "0";
16pub const MAX_NUMERIC_GRADE: i32 = 5;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum GradeScaleFamily {
20    PassFail,
21    Numeric,
22}
23
24pub fn grade_scale_family(grade_scale_id: &str) -> Option<GradeScaleFamily> {
25    match grade_scale_id {
26        PASS_FAIL_GRADE_SCALE_ID | PASS_FAIL_GRADE_SCALE_ID_ALT => Some(GradeScaleFamily::PassFail),
27        NUMERIC_GRADE_SCALE_ID => Some(GradeScaleFamily::Numeric),
28        _ => None,
29    }
30}
31
32/// Whether two scale ids name the same scale. The pass/fail id has two spellings in circulation, so
33/// comparing the strings would call an attainment we ourselves registered a different scale.
34pub fn same_grade_scale(left: &str, right: &str) -> bool {
35    match (grade_scale_family(left), grade_scale_family(right)) {
36        (Some(left), Some(right)) => left == right,
37        _ => left == right,
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct MappedGrade {
43    pub grade_scale_id: String,
44    pub grade_id: String,
45}
46
47/// What the completion says and what the module and the chosen enrolment say the scale should be.
48#[derive(Debug, Clone, Copy, PartialEq)]
49pub struct GradeSource<'a> {
50    pub passed: bool,
51    /// `None` for a pass/fail completion.
52    pub grade: Option<i32>,
53    /// The module's override, which is how one course is unblocked without a deploy.
54    pub configured_grade_scale_id: Option<&'a str>,
55    /// The scale the chosen enrolment says the registry expects.
56    pub enrolment_grade_scale_id: Option<&'a str>,
57}
58
59/// Maps a completion into the scale the registry expects, preferring what it told us to a guess.
60pub fn map_grade(source: GradeSource<'_>) -> Result<MappedGrade, CreditRegistrationErrorCode> {
61    let scale_id = source
62        .configured_grade_scale_id
63        .or(source.enrolment_grade_scale_id)
64        .unwrap_or(if source.grade.is_some() {
65            NUMERIC_GRADE_SCALE_ID
66        } else {
67            PASS_FAIL_GRADE_SCALE_ID
68        });
69    let family =
70        grade_scale_family(scale_id).ok_or(CreditRegistrationErrorCode::NoGradeScaleMapping)?;
71    let grade_id = match family {
72        GradeScaleFamily::PassFail => if source.passed {
73            PASS_GRADE_ID
74        } else {
75            FAIL_GRADE_ID
76        }
77        .to_string(),
78        GradeScaleFamily::Numeric => {
79            // Inventing a number would put a grade the teacher never gave on a transcript.
80            let grade = source
81                .grade
82                .ok_or(CreditRegistrationErrorCode::NoGradeScaleMapping)?;
83            if !(0..=MAX_NUMERIC_GRADE).contains(&grade) {
84                return Err(CreditRegistrationErrorCode::NoGradeScaleMapping);
85            }
86            grade.to_string()
87        }
88    };
89    Ok(MappedGrade {
90        grade_scale_id: scale_id.to_string(),
91        grade_id,
92    })
93}
94
95/// Whether a frozen pair is one the registry will accept. Checked again before batching: an unknown
96/// pair is a request-level rejection, so one bad row would fail twenty-four good ones.
97pub fn is_known_grade(grade_scale_id: &str, grade_id: &str) -> bool {
98    match grade_scale_family(grade_scale_id) {
99        Some(GradeScaleFamily::PassFail) => grade_id == PASS_GRADE_ID || grade_id == FAIL_GRADE_ID,
100        Some(GradeScaleFamily::Numeric) => grade_id
101            .parse::<i32>()
102            .is_ok_and(|grade| (0..=MAX_NUMERIC_GRADE).contains(&grade)),
103        None => false,
104    }
105}
106
107/// How a grade stands against one the registry already holds.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum GradeComparison {
110    Better,
111    /// Equal, worse, or a pair neither of which we recognise.
112    NotBetter,
113    /// TODO: nobody has told us how a number ranks against a pass, so the two scales are treated as
114    /// unrelated. Ask the study registry, and until then never act on a cross-scale difference.
115    NotComparable,
116}
117
118/// Whether `candidate` is worth pushing over `registered`, which is the frozen pair of an attempt
119/// the registry accepted.
120///
121/// `NotComparable` is not "unknown, try anyway": submitting on a cross-scale difference would ask
122/// the registry to replace a pass with a number, or the other way round, on a guess.
123pub fn compare_grades(
124    registered_grade_scale_id: &str,
125    registered_grade_id: &str,
126    candidate: &MappedGrade,
127) -> GradeComparison {
128    if !same_grade_scale(registered_grade_scale_id, &candidate.grade_scale_id) {
129        return GradeComparison::NotComparable;
130    }
131    let Some(family) = grade_scale_family(&candidate.grade_scale_id) else {
132        return GradeComparison::NotComparable;
133    };
134    match (
135        grade_rank(family, registered_grade_id),
136        grade_rank(family, &candidate.grade_id),
137    ) {
138        (Some(registered), Some(candidate)) if candidate > registered => GradeComparison::Better,
139        (Some(_), Some(_)) => GradeComparison::NotBetter,
140        _ => GradeComparison::NotComparable,
141    }
142}
143
144/// Where a grade sits within its own scale. Comparable only against another rank of the same scale.
145fn grade_rank(family: GradeScaleFamily, grade_id: &str) -> Option<i32> {
146    match family {
147        GradeScaleFamily::PassFail => match grade_id {
148            PASS_GRADE_ID => Some(1),
149            FAIL_GRADE_ID => Some(0),
150            _ => None,
151        },
152        GradeScaleFamily::Numeric => grade_id
153            .parse::<i32>()
154            .ok()
155            .filter(|grade| (0..=MAX_NUMERIC_GRADE).contains(grade)),
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn source(passed: bool, grade: Option<i32>) -> GradeSource<'static> {
164        GradeSource {
165            passed,
166            grade,
167            configured_grade_scale_id: None,
168            enrolment_grade_scale_id: None,
169        }
170    }
171
172    #[test]
173    fn a_completion_with_no_number_maps_to_the_pass_fail_scale() {
174        assert_eq!(
175            map_grade(source(true, None)),
176            Ok(MappedGrade {
177                grade_scale_id: PASS_FAIL_GRADE_SCALE_ID.to_string(),
178                grade_id: PASS_GRADE_ID.to_string(),
179            })
180        );
181    }
182
183    #[test]
184    fn a_graded_completion_maps_to_the_numeric_scale() {
185        assert_eq!(
186            map_grade(source(true, Some(4))),
187            Ok(MappedGrade {
188                grade_scale_id: NUMERIC_GRADE_SCALE_ID.to_string(),
189                grade_id: "4".to_string(),
190            })
191        );
192    }
193
194    #[test]
195    fn the_module_override_wins_over_the_enrolment_and_the_enrolment_over_the_guess() {
196        let with_enrolment = GradeSource {
197            enrolment_grade_scale_id: Some(PASS_FAIL_GRADE_SCALE_ID_ALT),
198            ..source(true, Some(4))
199        };
200        assert_eq!(
201            map_grade(with_enrolment).unwrap().grade_scale_id,
202            PASS_FAIL_GRADE_SCALE_ID_ALT
203        );
204        assert_eq!(map_grade(with_enrolment).unwrap().grade_id, PASS_GRADE_ID);
205
206        let overridden = GradeSource {
207            configured_grade_scale_id: Some(NUMERIC_GRADE_SCALE_ID),
208            ..with_enrolment
209        };
210        assert_eq!(
211            map_grade(overridden).unwrap().grade_scale_id,
212            NUMERIC_GRADE_SCALE_ID
213        );
214        assert_eq!(map_grade(overridden).unwrap().grade_id, "4");
215    }
216
217    #[test]
218    fn an_unrecognised_scale_fails_before_anything_is_sent() {
219        let source = GradeSource {
220            configured_grade_scale_id: Some("sis-something-else"),
221            ..source(true, Some(4))
222        };
223        assert_eq!(
224            map_grade(source),
225            Err(CreditRegistrationErrorCode::NoGradeScaleMapping)
226        );
227    }
228
229    #[test]
230    fn a_pass_fail_completion_cannot_be_pushed_into_a_numeric_scale() {
231        let source = GradeSource {
232            configured_grade_scale_id: Some(NUMERIC_GRADE_SCALE_ID),
233            ..source(true, None)
234        };
235        assert_eq!(
236            map_grade(source),
237            Err(CreditRegistrationErrorCode::NoGradeScaleMapping)
238        );
239    }
240
241    #[test]
242    fn a_number_outside_the_scale_does_not_map() {
243        assert_eq!(
244            map_grade(source(true, Some(7))),
245            Err(CreditRegistrationErrorCode::NoGradeScaleMapping)
246        );
247    }
248
249    #[test]
250    fn both_spellings_of_the_pass_fail_scale_are_the_same_scale() {
251        assert!(same_grade_scale(
252            PASS_FAIL_GRADE_SCALE_ID,
253            PASS_FAIL_GRADE_SCALE_ID_ALT
254        ));
255        assert!(!same_grade_scale(
256            PASS_FAIL_GRADE_SCALE_ID,
257            NUMERIC_GRADE_SCALE_ID
258        ));
259    }
260
261    #[test]
262    fn only_pairs_the_registry_knows_pass_the_pre_flight() {
263        assert!(is_known_grade(PASS_FAIL_GRADE_SCALE_ID, "1"));
264        assert!(is_known_grade(PASS_FAIL_GRADE_SCALE_ID_ALT, "0"));
265        assert!(is_known_grade(NUMERIC_GRADE_SCALE_ID, "5"));
266        assert!(!is_known_grade(NUMERIC_GRADE_SCALE_ID, "6"));
267        assert!(!is_known_grade(PASS_FAIL_GRADE_SCALE_ID, "3"));
268        assert!(!is_known_grade("sis-something-else", "1"));
269    }
270
271    fn mapped(grade_scale_id: &str, grade_id: &str) -> MappedGrade {
272        MappedGrade {
273            grade_scale_id: grade_scale_id.to_string(),
274            grade_id: grade_id.to_string(),
275        }
276    }
277
278    #[test]
279    fn only_a_higher_grade_on_the_same_scale_is_better() {
280        use GradeComparison::*;
281        let numeric = |grade: &str| mapped(NUMERIC_GRADE_SCALE_ID, grade);
282        assert_eq!(
283            compare_grades(NUMERIC_GRADE_SCALE_ID, "3", &numeric("4")),
284            Better
285        );
286        assert_eq!(
287            compare_grades(NUMERIC_GRADE_SCALE_ID, "4", &numeric("4")),
288            NotBetter
289        );
290        assert_eq!(
291            compare_grades(NUMERIC_GRADE_SCALE_ID, "4", &numeric("3")),
292            NotBetter
293        );
294        assert_eq!(
295            compare_grades(
296                PASS_FAIL_GRADE_SCALE_ID,
297                FAIL_GRADE_ID,
298                &mapped(PASS_FAIL_GRADE_SCALE_ID_ALT, PASS_GRADE_ID)
299            ),
300            Better
301        );
302        assert_eq!(
303            compare_grades(
304                PASS_FAIL_GRADE_SCALE_ID,
305                PASS_GRADE_ID,
306                &mapped(PASS_FAIL_GRADE_SCALE_ID, PASS_GRADE_ID)
307            ),
308            NotBetter
309        );
310    }
311
312    #[test]
313    fn a_grade_on_another_scale_is_never_an_improvement() {
314        use GradeComparison::*;
315        assert_eq!(
316            compare_grades(
317                NUMERIC_GRADE_SCALE_ID,
318                "3",
319                &mapped(PASS_FAIL_GRADE_SCALE_ID, PASS_GRADE_ID)
320            ),
321            NotComparable
322        );
323        assert_eq!(
324            compare_grades(
325                PASS_FAIL_GRADE_SCALE_ID,
326                PASS_GRADE_ID,
327                &mapped(NUMERIC_GRADE_SCALE_ID, "5")
328            ),
329            NotComparable
330        );
331        assert_eq!(
332            compare_grades(
333                "sis-something-else",
334                "3",
335                &mapped("sis-something-else", "4")
336            ),
337            NotComparable
338        );
339    }
340
341    /// A pair the registry would reject is not an improvement either: the comparison must not turn a
342    /// typo in the frozen snapshot into a resubmission.
343    #[test]
344    fn an_unreadable_grade_on_a_known_scale_is_not_comparable() {
345        assert_eq!(
346            compare_grades(
347                NUMERIC_GRADE_SCALE_ID,
348                "excellent",
349                &mapped(NUMERIC_GRADE_SCALE_ID, "5")
350            ),
351            GradeComparison::NotComparable
352        );
353    }
354
355    #[test]
356    fn everything_the_mapping_produces_passes_the_pre_flight() {
357        let mut mapped = vec![map_grade(source(true, None)).unwrap()];
358        for grade in 0..=MAX_NUMERIC_GRADE {
359            mapped.push(map_grade(source(grade > 0, Some(grade))).unwrap());
360        }
361        for grade in mapped {
362            assert!(
363                is_known_grade(&grade.grade_scale_id, &grade.grade_id),
364                "{grade:?}"
365            );
366        }
367    }
368}