Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Slice 5 — Exam Gradability

What this page is

An orientation map for the fifth Müsli slice (PR #1111, branch muesli-05-exam-grading).

TL;DR

Slice 5 turns raw points into grades.

  1. A grade scheme describes how points map to grades — a list of bands, each with a threshold and a grade.
  2. An applier analyses the point distribution, previews what a scheme would do, and writes the grades.
  3. Absence handling gives absent and exempt participations a defined outcome.
  4. Once a scheme has been applied it is frozen, so the mapping that produced existing grades cannot be edited underneath them.

This closes the chain: slice 1 collected points, slice 2 aggregated them, slice 3 decided who may sit, slice 4 ran the exam — slice 5 grades it.

New models

ModelPurposeNotable columns
Assessment::GradeSchemePoints-to-grade mapping for one assessmentkind (banded), config (jsonb), version_hash, active, applied_at, applied_by_id, points_step
Assessment::GradeSchemeApplierAnalyse, preview and apply a scheme(no table)
Assessment::AbsenceHandlingTransitions to absent/exempt(concern)

The band config

A scheme's config holds bands in one of two shapes — absolute or percentage, never mixed:

{ "bands": [ { "min_points": 54, "grade": "1.0" },
             { "min_points": 48, "grade": "1.3" },
             { "min_points": 0,  "grade": "5.0" } ] }
{ "bands": [ { "min_pct": 90, "grade": "1.0" },
             { "min_pct": 0,  "grade": "5.0" } ] }

GradeScheme.two_point_auto generates the absolute shape from an excellence and a passing point value, spreading the ten passing grades evenly and rounding to points_step.

How it relates

erDiagram
    Exam ||--o| Assessment : "assessable"
    Assignment ||--o| Assessment : "assessable"
    Assessment ||--o{ GradeScheme : "has many (one active)"
    Assessment ||--o{ Participation : "has many"
    GradeScheme }o--o| User : "applied_by"
    Participation }o--o| User : "grader"

A scheme belongs to an assessment, not to a lecture or an exam

Because Assessment is polymorphic, the same machinery grades an exam and an assignment. Nothing is exam-specific — the exam only appears as the assessable.

Percentage bands divide by effective_total_points

That is slice 1's value: the sum of the tasks' max_points, with no column to override it — see Tasks are the only source. Adding or removing a task therefore shifts every percentage-based grade.

Results above 100 % are normal, because task points are not capped at the task maximum. apply_percentage_scheme sorts bands descending and matches with >=, so such a result lands in the top band rather than falling through.

Applying a scheme

flowchart TD
    A[Draft scheme] -->|analyze_distribution| B[Histogram, mean, percentiles]
    A -->|preview_all| C[Proposed grade per student]
    C -->|apply!| D[Grades written, applied_at set]
    D -->|scheme frozen| E[Re-apply: only ungraded participations]

The second run is the interesting one: after applied_at is set, apply! narrows its target to participations that have no grade yet, so grades entered or corrected by hand are not overwritten. See Behavior Highlights.

Concerns another PR: what muesli/tutor-grading-view has to honour

Nothing in slices 1–5 writes a grading state. No controller enters task points, none sets reviewed, none marks a participant absent or exempt. Everything that puts a participation into one of those states is being built on the tutor grading branches: task points through SubmissionGraderService, a talk's grade through GradeEntryService, exams still to come.

Two rules the model layer here already assumes, and which that branch has to keep:

"Grading complete" needs points, on a points-based assessment. If a participation reaches reviewed with no task points at all, applying a scheme writes it a 5.0 — compute_grade_for sees a nil total and falls back. It cannot tell a premature click apart from a seminar talk, which legitimately has no points at all. So the action that sets the status has to refuse the empty case; the fallback is a guard, not a feature. See what reviewed requires.

Excusing someone goes through AbsenceHandling, not through a status write. mark_exempt clears grade_numeric, grader and graded_at, which is what takes back the 5.0 a no-show was given. Setting status: :exempt directly would leave the failing grade in place, and re-applying the scheme would not remove it either — exempt participations are never targeted.

The model layer for absences is finished and specced; only the caller is missing:

PieceState
mark_absent(participation)done — sets absent, nulls submitted_at
mark_exempt(participation, note:)done — plus the note, and clears the grade
Reviewed-transition guarddone — raises InvalidTransitionError
note column for the certificate referencemigrated, unused
absent / exempt enum valuesdone
Model specsabsence_handling_spec, 10 examples

Before you read the code

Five places in this diff that look like working features but are groundwork. The rules behind them live in Grading Schemes and Assessments & Grading; this page says what you need in order to read this branch.

two_point_auto is never called

The Ruby generator carries the algorithm, all five input guards and thirteen specs — and no caller. The bands a teacher actually gets are built in the browser by computeBands, which has no tests, and the input guards sit in a third file, scheme_form.controller.js. The two implementations agree today; nothing keeps them in step. See the generator exists twice.

Percentage bands can be read but not created

apply_percentage_scheme, the validation and the summary all handle min_pct bands, and specs cover them. Nothing in the application writes one — there is no generator, and the form only offers points. A percentage scheme can therefore only arrive through a seed or the console.

Note also that a band is a lower bound only. A max_points or max_pct key is ignored; the highest band a student reaches wins.

version_hash is written and never read

It is recomputed on every config change and stored, but no code compares it. The idempotency of a second apply! comes from applied_at. Treat the column as prepared, not load-bearing.

The 5.0 fallbacks guard states that cannot arise here

compute_grade_for returns 5.0 when points_total is nil, and again when a percentage scheme finds no maximum. Both are backstops: the first would mean a participation marked reviewed with nothing entered, which the action that sets the status has to refuse — and that action is not in this stack. Reading them as grading rules would be a mistake.

The absent branch of the applier is unreachable

apply! grades absent participations 5.0 and skips exempt ones, and that is the right rule — see absent and exempt are opposites. But nothing can set either status yet, so absent_participations is always empty. The box above says what the branch that wires it has to honour.

New screens

ScreenWhat it does
assessment/assessments/components/scheme_form_componentBuild a scheme: bands, thresholds, live preview
assessment/assessments/components/grade_scheme_summary_componentRead-only summary of the active scheme
assessment/assessments (distribution, preview partials)Point histogram and the effect of a scheme
exams/componentsGrading tab on the exam
rosterGrade columns in the roster view

Two Stimulus controllers: scheme_form (drives the builder and the preview) and the preview renderer it delegates to.

New controller

Assessment::GradeSchemesController — create, edit, preview, apply, destroy.

Migrations

MigrationEffect
…000003_create_grade_schemestable with jsonb config, a partial unique index on one active scheme per assessment
…000004_add_points_step_to_grade_schemesdecimal(10,2), default 1.0

Suggested reading order (~25 min)

  1. app/models/assessment/grade_scheme.rb — the config contract and the immutability rule
  2. app/models/assessment/grade_scheme_applier.rb — compute_grade_for first, then apply!
  3. app/models/assessment/absence_handling.rb — short, but it defines what "absent" means for a grade
  4. app/controllers/assessment/grade_schemes_controller.rb

Previous: Slice 4 — Exam Core & Registrations