Gradebook: Weighted Categories, Late Work and Curves
MediumAI-assisted coding · AI-Assisted Debugging
District Gradebook
You've joined the student-systems team at the Maple Ridge School District. The gradebook service turns the marks teachers record into the percentages and letter grades printed on report cards. Last Friday's progress reports went out with grades the teachers immediately disputed (tickets GRD-301 to GRD-312, Part 1). Once those are fixed, the board has approved two policy changes (Part 2), and teachers and the registrar have asked for grade curves and an audit trail (Part 3). All grade arithmetic is exact: inside the service every number is a fractions.Fraction, and nothing may ever go through floating point.
Codebase
| File | What it does |
|---|---|
gradebook/api.py | run(operations) entry point and the Dispatcher that routes each operation |
gradebook/service.py | Gradebook: the operations, with argument validation |
gradebook/courses.py | CourseBook: courses, rosters, assignments and recorded marks |
gradebook/scoring.py | Decides which assignments count for a student and for how many points |
gradebook/calculator.py | Category percentages and the weighted course percentage |
gradebook/report.py | How grades are displayed: percent text, letters, class report order |
gradebook/numbers.py | Parsing of points and other decimals |
gradebook/names.py | Category-name normalization |
gradebook/clock.py | The school calendar (one clock shared by all courses) |
gradebook/models.py | Course, Category, Assignment, Cell |
gradebook/curves.py, gradebook/audit.py | Stubs for Part 3 |
gradebook/config.py, gradebook/errors.py | Letter cutoffs, constants and every error code |
tests/ | Existing example tests |
Conventions that live in the code, not here: the letter cutoffs and the "incomplete" marker are in config.py; the display rules are in report.py (this README states them in "Reading a grade"); category names are matched through names.canonical_category (trim, collapse inner whitespace, case-fold; ids are *not* normalized); scoring.counted_items is the one place that decides what counts.
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh gradebook and returns one result per operation. Every operation is [name, *args]. Errors are returned as "error: <code>" strings, never raised. An unknown (or non-string) operation name returns "error: unknown operation"; a wrong number of arguments returns "error: invalid arguments". A failed operation changes nothing.
- Ids (
course_id,assignment_id,student_id,prefix) and category names must be non-empty strings, otherwiseinvalid arguments(checked first, before any lookup). - Points (marks and points possible) are a non-negative integer or a decimal string with at most two decimals (
"7.5","17.99","0.25"). Floats, booleans, negative or malformed values areinvalid points. - Days are non-negative integers counted from the first day of term (day 0); booleans are not integers. The registrar's clock starts at day 0 and is shared by every course.
| Operation | Arguments | Returns | Errors (checked in this order) |
|---|---|---|---|
open_course | course_id, categories | true | invalid arguments (also: categories not a non-empty list of 2-element lists), course exists, then per category in order invalid category (name empty after normalizing, or weight not an integer 1..100), duplicate category; finally weights must total 100 |
enroll | course_id, student_id | true | course not found, already enrolled |
enroll_many | course_id, prefix, count | count | invalid arguments (also: count not an integer 1..5000), course not found, already enrolled (nothing is enrolled). Enrolls <prefix>001, <prefix>002, ... (numbers zero-padded to at least 3 digits) |
set_day | day | the new day | invalid day, clock cannot go back (setting the same day again is fine) |
add_assignment | course_id, assignment_id, category, points_possible, due_day | true | course not found, assignment exists, unknown category, invalid points (also when zero), invalid day |
score | course_id, assignment_id, student_id, points | true | course not found, assignment not found, student not enrolled, invalid points (also when above the assignment's points possible) |
excuse | course_id, assignment_id, student_id | true | course not found, assignment not found, student not enrolled |
grade | course_id, student_id | grade object | course not found, student not enrolled |
class_report | course_id | list of rows | course not found |
score_all | course_id, assignment_id, base, step | number of students | invalid arguments (also: base/step not non-negative integers), course not found, assignment not found |
- A course's categories are
[name, weight]pairs; weights are whole percents that total exactly 100. scoreandexcusereplace whatever was recorded before for that student and assignment: a new score replaces an excuse, an excuse replaces a score. Nothing recorded means "not yet recorded".score_allis a bulk helper for load testing: the *i*-th enrolled student (counting from 0) gets(base + i*step) mod (floor(points_possible) + 1)points, replacing whatever was recorded.
Reading a grade
grade returns {"percent": <text or null>, "letter": <letter>, "categories": [[name, text or null], ...]} with the categories in the course's creation order and names in canonical form.
- Percent text has exactly one decimal place and rounds halves up (
84.25shows as"84.3",72.349as"72.3"), computed exactly. - Letters:
Aat 90 or more,Bat 80,Cat 70,Dat 60, otherwiseF. The letter is decided from the exact percentage, not from the text that is shown: 89.95 shows as"90.0"and is aB. - A student with nothing countable at all shows
{"percent": null, "letter": "INC", ...}. class_reportreturns[student_id, percent text, letter]rows ordered by exact percentage, highest first, ties bystudent_idin plain string order; students withINCcome last, also bystudent_id.
Part 1: Report-card grades are wrong (GRD-301, GRD-305, GRD-309, GRD-312)
Goal: make grade and class_report follow the rules below.
Complaints from teachers and the registrar:
- "My students have no quiz grades yet and their report cards show a failing grade." (GRD-301)
- "Excused homework counts against the student, and an assignment due *today* is already a zero." (GRD-305)
- "84.25 printed as 84.2. And the calculator says 17.99 out of 20 is a 90, the gradebook says an 89.9." (GRD-309)
- "The class report puts a student with 100.0 below a student with 99.0." (GRD-312)
Rules
- What counts, per student and assignment: *excused* (the teacher excused it) does not count; *scored* counts for the recorded points; nothing recorded and
today > due_dayis *missing* and counts as 0 points; nothing recorded andtoday <= due_dayis *not yet due* and is ignored. (An assignment is due at the end of its due day.)todayis the shared clock. - Category percentage: total points earned divided by total points possible over the assignments of that category that count (so a 40-point assignment weighs four times a 10-point one). A category in which nothing counts has no percentage and takes no part in the course grade (
nullincategories). - Course percentage: the weight-weighted average of the percentages of the categories that take part, renormalizing the weights:
sum(weight * percentage) / sum(weights of participating categories). If no category takes part, the student isINC. - Display rules are as in "Reading a grade". Category percentages in
categoriesuse the same text rule.
Example
[["open_course", "alg1", [["Homework", 20], ["Quizzes", 30], ["Exams", 50]]],
["enroll", "alg1", "ana"], ["enroll", "alg1", "ben"], ["enroll", "alg1", "cy"], ["enroll", "alg1", "dan"],
["add_assignment", "alg1", "hw1", "homework", 10, 3], ["add_assignment", "alg1", "hw2", "homework", 20, 6],
["add_assignment", "alg1", "q1", "quizzes", 20, 4], ["add_assignment", "alg1", "ex1", "exams", 100, 9],
["set_day", 7],
["score", "alg1", "hw1", "ana", 8], ["score", "alg1", "q1", "ana", 15],
["score", "alg1", "hw1", "ben", 10], ["excuse", "alg1", "hw2", "ben"], ["score", "alg1", "q1", "ben", 12],
["excuse", "alg1", "hw1", "dan"], ["excuse", "alg1", "hw2", "dan"], ["excuse", "alg1", "q1", "dan"],
["grade", "alg1", "ana"], ["grade", "alg1", "ben"], ["class_report", "alg1"]]On day 7 the exam (due day 9) is not yet due, so the exams category takes no part. ana missed hw2: homework is 8/30, quizzes 15/20, and the course is (20*8/30 + 30*3/4) / 50 = 55.67, shown as {"percent": "55.7", "letter": "F", "categories": [["homework", "26.7"], ["quizzes", "75.0"], ["exams", null]]}. ben has homework 10/10 (the excused hw2 is out) and quizzes 12/20: "76.0", C. cy recorded nothing, so both homeworks and the quiz are zeros (0.0, F); dan had everything excused (INC). The class report is [["ben", "76.0", "C"], ["ana", "55.7", "F"], ["cy", "0.0", "F"], ["dan", null, "INC"]].
Part 2: Board policy: late work, dropped scores, extra credit (GRD-340)
Goal: implement the board's three policies. Part 1 behaviour is unchanged when no policy is set.
| Operation | Arguments | Returns | Errors (in order) |
|---|---|---|---|
set_late_policy | course_id, per_day, cap | true | course not found, invalid percent (each is a decimal as for points, at most 100; per_day is checked before cap) |
set_drop | course_id, category, count | true | course not found, unknown category, invalid drop count (an integer 0..10) |
add_assignment | optional 6th argument extra_credit (true/false) | true | a non-boolean sixth argument is invalid arguments |
score | optional 5th argument submitted_day (a day or null) | true | invalid day, checked after invalid points |
Rules
- Late penalty. The default policy is
0per day and0cap (no penalty). A score withsubmitted_daydon an assignment due on dayDismax(0, d - D)days late. The penalty ismin(per_day * days, cap)percent of the assignment's points possible (not of the points earned), subtracted from the points, and the result never goes below 0. A score withoutsubmitted_dayis on time. Missing work (a zero) has no penalty. Recording a new score replaces the earliersubmitted_daytoo (leaving it out means on time). The policy is a rule applied when grades are computed, so changing it also changes scores recorded earlier. - Drop lowest.
set_dropsets how many of the lowest items to drop from one category (default 0). The items eligible are the category's *counting regular* assignments from Part 1 (so excused and not yet due ones are never in play, but missing zeros are eligible and are usually the first to go). "Lowest" means the lowest percentage (points after the late penalty divided by points possible); ties are broken by more points possible first, then byassignment_idin plain string order. At mosteligible - 1items are dropped, so one item always remains. The category percentage is then computed over the items that are left. - Extra credit. An extra-credit assignment's points possible are not added to the category's denominator; the points a student earns on it are added to the numerator. It is never eligible to be dropped, and if the student has no score on it (not turned in, even when past due) it simply does not exist for that student. The late penalty applies to its points like any other. A category's percentage is capped at 100% after extra credit is added. A category with only extra-credit items counting (no regular item) takes no part in the course grade.
Example
[["open_course", "bio", [["Labs", 40], ["Tests", 60]]], ["enroll", "bio", "mia"],
["add_assignment", "bio", "l1", "labs", 10, 2], ["add_assignment", "bio", "l2", "labs", 40, 4],
["add_assignment", "bio", "l3", "labs", 20, 6], ["add_assignment", "bio", "t1", "tests", 50, 5],
["add_assignment", "bio", "b1", "labs", 5, 6, true],
["set_day", 7], ["set_late_policy", "bio", "10", "30"], ["set_drop", "bio", "labs", 1],
["score", "bio", "l1", "mia", 8], ["score", "bio", "l2", "mia", 36, 6], ["score", "bio", "l3", "mia", 13],
["score", "bio", "t1", "mia", 45], ["score", "bio", "b1", "mia", 4],
["grade", "bio", "mia"]]l2 was two days late: 20% of 40 points is 8, so it is worth 28/40. The labs percentages are l1 80%, l2 70% and l3 65%, so l3 is dropped (dropping by raw points would drop l1). The labs are (8 + 28) / (10 + 40) = 72%, plus the 4 extra-credit points over the same 50: 80%. Tests are 90%, and the course is 0.4*80 + 0.6*90 = 86.0: {"percent": "86.0", "letter": "B", "categories": [["labs", "80.0"], ["tests", "90.0"]]}.
Part 3: Curves and the grade audit trail (GRD-377, GRD-380)
Goal: teachers can curve a single assignment, and the registrar can see every change to a student's displayed grade. A curve is applied to the points of each assignment before anything is totalled, which changes what the earlier Parts' rules operate on.
| Operation | Arguments | Returns | Errors (in order) |
|---|---|---|---|
curve | course_id, assignment_id, kind, value | true | invalid arguments (also: kind not a string), course not found, assignment not found, invalid curve |
history | course_id, student_id | list of entries | course not found, student not enrolled |
Curve rules. kind is one of "add", "median", "sqrt" or "none"; anything else is invalid curve. An assignment has at most one curve: a new curve call replaces the old one, and "none" removes it.
"add":valueis a decimal (as for points) greater than 0; every curved score gets that many more points."sqrt":valuemust benull. A raw scoreron an assignment worthPpoints becomessqrt(r * P)rounded down to a multiple of 0.01, computed exactly (36 of 100 becomes 60; 50 of 100 becomes 70.71)."median":valueis a target percentage, a decimal greater than 0 and at most 100. Take the *raw* points of every enrolled student who has a recorded score on this assignment (excused students, missing work and students with nothing recorded are not part of it), find their median (the mean of the two middle values when there is an even number of them), and letbump = max(0, value% of P - median). Every one of those students' scores getsbumpmore points. The curve is a rule evaluated on the scores as they stand: if any student's score changes, the bump changes for everyone.- A curve applies to recorded scores only; missing work stays a zero. Invalid
values (wrong type for the kind, out of range, or notnullfor"sqrt"/"none") areinvalid curve. - Order of adjustments for one score: curve, then cap at the assignment's points possible, then the late penalty (Part 2), then the floor at 0. Everything after that, including which items are the lowest for
set_drop, works with these adjusted points. Extra-credit assignments can be curved too.
Audit rules. After every operation that succeeds, each enrolled student whose displayed grade (percent text and letter) is different from what it was before the operation gets one entry in their history, oldest first: {"op": <0-based position of the operation in the run() list>, "name": <operation name>, "from": {"percent": ..., "letter": ...}, "to": {...}}. This includes changes caused by *someone else's* mark under a median curve, by set_day making work overdue, by a new assignment, by a policy change and by score_all. A change in the exact percentage that leaves the displayed percent and letter the same is not logged; a letter change with the same text is. Enrolling a student sets their starting grade and writes no entry. Failed operations write nothing.
Scale. Classes of a few hundred students with several median-curved assignments and hundreds of marks recorded one at a time must still run in a couple of seconds.
Example
[["open_course", "hist", [["Essays", 100]]],
["enroll", "hist", "ana"], ["enroll", "hist", "ben"], ["enroll", "hist", "cy"], ["enroll", "hist", "dan"],
["add_assignment", "hist", "e1", "essays", 100, 3],
["score", "hist", "e1", "ana", 60], ["score", "hist", "e1", "ben", 70],
["score", "hist", "e1", "cy", 80], ["score", "hist", "e1", "dan", 90],
["curve", "hist", "e1", "median", 85],
["score", "hist", "e1", "ana", 75],
["history", "hist", "ben"]]The raw median is 75, so the curve adds 10 points to everyone (ana 70, ben 80, cy 90, dan 100). Re-scoring ana as 75 moves the median to 77.5, the bump to 7.5, and every student's grade, including ben's: [{"op": 7, "name": "score", "from": {"percent": null, "letter": "INC"}, "to": {"percent": "70.0", "letter": "C"}}, {"op": 10, "name": "curve", "from": {"percent": "70.0", "letter": "C"}, "to": {"percent": "80.0", "letter": "B"}}, {"op": 11, "name": "score", "from": {"percent": "80.0", "letter": "B"}, "to": {"percent": "77.5", "letter": "C"}}].
Notes
- You may use the AI assistant freely; you are expected to verify what it produces.
- Later Parts must not break earlier ones; hidden tests re-check earlier behaviour after each Part.
- If this README and the code seem to disagree about letter cutoffs or name handling,
config.pyandnames.pywin; for everything else the README wins.
Example 1
- Input:
- [["open_course","alg1",[["Homework",20],["Quizzes",30],["Exams",50]]],["enroll","alg1","ana"],["add_assignment","alg1","hw1","homework",10,3],["add_assignment","alg1","hw2","homework",20,6],["add_assignment","alg1","q1","quizzes",20,4],["add_assignment","alg1","ex1","exams",100,9],["set_day",7],["score","alg1","hw1","ana",8],["score","alg1","q1","ana",15],["grade","alg1","ana"]]
- Output:
- [true,true,true,true,true,true,7,true,true,{"percent":"55.7","letter":"F","categories":[["homework","26.7"],["quizzes","75.0"],["exams",null]]}]
- Explanation
- hw2 is past due and unscored, so homework is 8/30. The exam is not yet due, so its category takes no part and the weights renormalize: (20*8/30 + 30*3/4)/50 = 55.67.
Example 2
- Input:
- [["open_course","hist",[["Essays",100]]],["enroll","hist","ana"],["enroll","hist","ben"],["add_assignment","hist","e1","essays",100,3],["score","hist","e1","ana",60],["score","hist","e1","ben",80],["curve","hist","e1","median",85],["history","hist","ben"]]
- Output:
- [true,true,true,true,true,true,true,[{"op":5,"name":"score","from":{"percent":null,"letter":"INC"},"to":{"percent":"80.0","letter":"B"}},{"op":6,"name":"curve","from":{"percent":"80.0","letter":"B"},"to":{"percent":"95.0","letter":"A"}}]]
- Explanation
- The raw median is 70, so the median curve adds 15 points to everyone: ben goes from 80.0 (B) to 95.0 (A), which is an audit entry for ben, caused by the curve and not by anything ben did.
Guidelines
- Python 3 standard library only
- All grade arithmetic is exact (fractions.Fraction); no floating point anywhere, including when parsing points
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- Display and letter rules live in gradebook/report.py and gradebook/config.py; category names always go through gradebook/names.py