Skip to content

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).

The gradebook already carries every structural piece weighting needs, but never used them to produce a number:

  • grade_category (org-scoped, optional) with a nullable weight numeric(8,4).
  • assessment_definition.grade_category_id and lti_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.

  1. 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.

  2. 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 and max > 0; exempt, retracted, ungraded, and max ≤ 0 cells 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 a null final grade (shown as “—”), never 0.

  3. weight is 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 enter 20 / 50 / 30 or 0.2 / 0.5 / 0.3 and 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.

  4. 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.

  5. 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.

  6. 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.

  • Pure core (gradebook-weighting.ts): computeWeightedFinalGrades({ columns, categories, cells, personIds }){ finalGrades, weightingConfigured }, where each cell already carries a resolved ratio: number | null (the caller computes score ÷ max with the correct max source and passes null to skip). DB-free and exhaustively unit-tested.
  • Projection: listTeacherOfferingGradebook builds columns/categories/cells from the rows it already reads (native entries with entry_status = 'graded'; external results with a numeric score), calls the pure core, and adds finalGrades: { personId, weightedPercentage, breakdown[] } plus weightingConfigured: boolean (≥1 positive-weight category with ≥1 assigned column) to the response. gradeCategories keeps 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 category Select).

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.

None. No schema change, no migration, no DB step.