Naalya Handbook
Grading

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.

  1. 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.
  2. 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.
  3. 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:

ModeBehaviour
half_up (default)2.5 → 3 — classical school rounding
half_evenBanker's rounding — exact .5 ties go to the nearest even digit
floorTruncate

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.

PieceRules (validated on create/update)
bandsMust tile 0–100 exactly: first starts at 0, last ends at 100, each min = prev.max + 1 — no gaps, no overlaps
groupsWeights must sum to 100 (±0.01)
roundingMode, precisionspercentPrecision ≥ 0 (default 0), scoreGradePrecision ≥ 0 (default 1), maxScoreGrade > 0 (default 3)
  • Templates: GET /grading/templates serves GRADING_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/default seeds it verbatim (and skips band/group validation — the template is trusted).
  • Update semantics differ by collection: supplying bands hard-deletes and recreates the whole set; supplying groups reconciles 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 offers POST /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

On this page