Skip to main content

headless_lms_models/library/credit_registration/
grade_mapping.rs

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