Grading Engine
The competency grading system — a pure compute engine over per-level config, with banded results and write-through to the gradebook.
Grading turns assessment scores into a subject-term grade — a percent, a letter band with descriptor, and a score-grade — using a pure compute engine over a config per campus-year and class level. "Pure" is the load-bearing word: compute/compute-grade.ts does no I/O at all — config in, scores in, GradeResult out — which is what makes the math unit-testable and identical everywhere it runs.
How a grade is computed
The tree has two weighted levels: assessments → groups → final.
- Per group: each assessment contributes
score / maxScore × 100, weighted. Weights normalise by the actual sibling sum, not by 100 — a group holding a single weight-40 assessment still averages correctly, which is also why assessment creation only rejects over-allocation (see Assessments & Scores). The group percent is then rounded per config. - Final: a weighted average over the rounded group percents (deliberately rounded-then-combined, per the design spec — so what a teacher sees per group is exactly what feeds the total), normalised over the group weights.
- Band lookup: the final percent lands in a band (
minPercent ≤ pct ≤ maxPercent) →letter+descriptor;scoreGrade = pct/100 × maxScoreGrade, separately rounded.
Incomplete never writes. A group with zero assessments, any score === null, or a non-positive maxScore marks the whole result incomplete — and the orchestrator (grading-compute.service.ts) no-ops rather than write a partial grade or clobber a manual entry. A gradebook with one missing score simply keeps its previous grade until the score arrives.
Rounding is its own tiny module (compute/round.ts) because IEEE-754 makes naive rounding drift:
| Mode | Behaviour |
|---|---|
half_up (default) | 2.5 → 3 — classical school rounding |
half_even | Banker's rounding — exact .5 ties go to the nearest even digit |
floor | Truncate |
The write lands via gradeRepository.upsert on (enrollmentId, termId, subjectId): score = final percent, maxScore = 100, grade = letter, plus scoreGrade, descriptor, campusAcademicYearId, and gradedBy (taken from the most-recently-updated contributing score's enteredBy).
The config
One grading_config per (campus academic year, level) — a DB unique constraint, not per campus alone, so O-Level and A-Level carry different scales and next year can change them without touching history. Its FK to campus_x_academic_year is pinned with @JoinColumn({ referencedColumnName: 'id' }) — the entity comment warns TypeORM otherwise emits a broken composite FK; copy that shape on any new cay-referencing entity.
| Piece | Rules (validated on create/update) |
|---|---|
bands | Must tile 0–100 exactly: first starts at 0, last ends at 100, each min = prev.max + 1 — no gaps, no overlaps |
groups | Weights must sum to 100 (±0.01) |
roundingMode, precisions | percentPrecision ≥ 0 (default 0), scoreGradePrecision ≥ 0 (default 1), maxScoreGrade > 0 (default 3) |
- Templates:
GET /grading/templatesservesGRADING_TEMPLATES— currently one,ugandan-olevel-competency(5 bands, A "Exceptional" 85–100 down to E "Elementary" 0–49; two groups A1/A2 at 50/50).POST /grading/configs/defaultseeds it verbatim (and skips band/group validation — the template is trusted). - Update semantics differ by collection: supplying
bandshard-deletes and recreates the whole set; supplyinggroupsreconciles by id — id present = update, absent id = create, existing group omitted = soft-delete the group and its assessments. No auto-recompute follows (deliberate load-spreading); the UI shows a staleness banner and offersPOST /grading/configs/:id/recompute. - Creation requires the year current (
assertYearIsCurrent) and rejects duplicates per (cay, level).
Recompute fan-out
GradingComputeService exposes targeted recomputes — recomputeEnrollmentSubjectTerm (one student), recomputeSubjectTerm (roster derived from the score table), recomputeStream (active enrollments), recomputeCampusAcademicYear(cayId, level?). They run synchronously in the HTTP request on purpose: @TenantScoped repositories need the request's tenant context, and moving this to a queue would mean re-plumbing tenancy — don't "optimize" it into a job without reading escape hatches first.
Permissions
Resource.GRADING_CONFIG (campus-scoped) guards the config controller per action and the templates controller at class level. The assessment and score controllers carry no @RequirePermissions — authorization is teaching-relationship-based in GradingAccessService (see Assessments & Scores).
Where to go next
Student Runtime & Grading
The attempt lifecycle — start/resume, lazy expiry, all-or-nothing auto-grading, teacher finalise, and the write-through to the gradebook.
Assessments & Scores
Per-stream assessments with a group weight budget, the lazy score-entry matrix, and the guarded upsert that feeds the compute engine.