Grade-category weighting (computed final grade)
Status: accepted
Date: 2026-06-26
Relates to: oneroster-gradebook-resources-v1-brief, ADR-0007 (server-written gradebook
entries), ADR-0040 (LTI line items as first-class gradebook columns).
Context
Section titled “Context”The gradebook already carries every structural piece weighting needs, but never used them to produce a number:
grade_category(org-scoped, optional) with a nullableweight numeric(8,4).assessment_definition.grade_category_idandlti_platform_line_item.grade_category_id— a native assessment column or an external LTI line-item column binds to at most one category.- Categories already round-trip through the OneRoster gradebook provider/consumer and CSV
import/export, where weight is native to OneRoster’s
Category.
The glossary recorded final-grade weighting as the deliberately deferred hook: “Provides the hook for final-grade weighting (deferred).” Everything was plumbed; nothing aggregated. A teacher could see per-column grades but never a course total, and the weights they (or an SIS) set were inert. This ADR closes that — it is the last deferred item from the gradebook lane.
Because the schema already holds weight and both grade_category_id columns, no migration is
required and there is no DB step for the operator.
Decisions
Section titled “Decisions”-
The final grade is computed on read, never stored. It is a projection in the teacher gradebook response, recomputed from the live
gradebook_entry/ line-item results each read — not a persisted row, not a new table, not a synthetic gradebook entry. A stored cumulative grade would drift the moment any cell, weight, or category assignment changed; a pure projection is always consistent and needs no migration, no erasure entry, and no new sync table. -
Algorithm: weight-normalised mean of category percentages over the categories the learner has graded work in (“current grade” semantics). Per column,
percentage = score ÷ max(a column contributes only when the learner has a numeric, finalised score andmax > 0;exempt,retracted, ungraded, andmax ≤ 0cells are skipped, never counted as zero). Per category,categoryPercentage = unweighted mean of its contributing columns' percentages(columns inside a category are equally weighted — the standard default). The final isΣ(categoryPercentage × weight) ÷ Σ(weight)over only the categories that have a positive weight and at least one contributing column for that learner. This deliberately normalises over present categories so a learner is never penalised for a category that has not been graded yet — the displayed total is the weighted average “so far”, matching how Canvas/Moodle show a running total. A learner with no graded work in any weighted category has anullfinal grade (shown as “—”), never0. -
weightis a relative number, not a mandated fraction. Storage is unchanged (numeric(8,4), OneRoster-compatible), but because the algorithm normalises byΣ(weight)the absolute scale is irrelevant: a teacher may enter20 / 50 / 30or0.2 / 0.5 / 0.3and get the same result. The UI shows each category’s normalised share (weight ÷ Σweight, e.g. “Homework — weight 20 · 33%”) so the meaning is explicit regardless of the scale chosen. Weights need not sum to anything in particular. -
Native assessments and external LTI line items both participate. Both column kinds carry a
grade_category_id; the glossary already calls a line item “weightable in a Grade Category”. The weighting treats them uniformly — an LTI quiz in the “Exams” category counts toward Exams exactly like a native assessment does. -
Final grade is a percentage; no score-scale / letter mapping in this increment. The computed total is a 0–100 percentage. Mapping it through a
score_scale(letter bands) is a separate, later concern; the percentage is scale-agnostic and is the honest primitive. -
Self-serve management lives in the teacher gradebook, conservatively. So weighting works without an SIS, a teacher can, from a single “Manage weighting” modal: create / rename / re-weight / archive the offering org’s grade categories, and assign each column (native assessment or LTI line item) to a category. Category assignment is placement metadata, so it is editable even on a published assessment (unlike measurement-bearing fields, which stay frozen). The spreadsheet grid itself stays read-only and conservative — it gains one Final column and category labels on headers; all editing is in the modal, no innovation in the grid.
Design
Section titled “Design”- Pure core (
gradebook-weighting.ts):computeWeightedFinalGrades({ columns, categories, cells, personIds })→{ finalGrades, weightingConfigured }, where each cell already carries a resolvedratio: number | null(the caller computesscore ÷ maxwith the correct max source and passesnullto skip). DB-free and exhaustively unit-tested. - Projection:
listTeacherOfferingGradebookbuildscolumns/categories/cellsfrom the rows it already reads (native entries withentry_status = 'graded'; external results with a numeric score), calls the pure core, and addsfinalGrades: { personId, weightedPercentage, breakdown[] }plusweightingConfigured: boolean(≥1 positive-weight category with ≥1 assigned column) to the response.gradeCategorieskeeps its “referenced by a column” scope for the grid. - Management endpoints (teacher-authorised, org-scoped):
- list / create / update grade categories for an offering’s org;
- assign a category to a native assessment (extends the existing assessment-update endpoint, allowed while published);
- assign a category to an LTI line item.
- UI: a Final column + header category labels in
ResultsTab, a category-weight legend, and a Mantine “Manage weighting” modal (category editor + per-column categorySelect).
Conformance impact
Section titled “Conformance impact”None. OneRoster category weight already round-trips; this ADR only consumes the weight that was already imported/exported. No board move, no new spec surface.
Operator follow-ups
Section titled “Operator follow-ups”None. No schema change, no migration, no DB step.