1
0

Phase 6: participation grading, gradebook passback, opt-out integrity

Ports the timing-neutral foregone-participation grading scheme from
participation_grades.R: answer quality (per-course level points) minus
a deduction for participation foregone through unavailability,
estimated by Monte Carlo simulation of the actual weighted draw and
averaged over when absences fall, plus a small form-filing incentive
for drawn-while-absent-without-opt-out days. Reason-blind and
luck-protected; zero-answer students floor to 0. Parameters
(allowance in SD units, passing line, form penalty, simulation
size/seed) are course settings. Verified against the R engine's
rendered 2026q2 reports via the new import-legacy command: quality and
availability match exactly, finals within Monte Carlo noise; dropped
students import as inactive enrollments and are excluded identically.

Grades are computed on demand into stored GradeRun snapshots and
reviewed on a grades page with CSV export and per-student reports
(also served to students via a publish toggle). Display scales map
points to UW 4.0, a threshold table (one-click import of the Canvas
course grading scheme), or raw points. Gradebook passback via AGS
sits behind a settings toggle with a review-then-push flow.

Opt-out withdrawals are now soft-deletes with a withdrawn_at audit
trail, and close when class begins (class days gained optional start
times), so availability records cannot be rewritten after the fact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 18:38:22 -07:00
parent 7ec1be5dc6
commit 46f7f55633
27 changed files with 1869 additions and 98 deletions

238
tests/test_grading.py Normal file
View File

@@ -0,0 +1,238 @@
import datetime
import pytest
from coldcall_lti import grading, models, scales
D0 = datetime.date(2026, 10, 1)
def setup_course(db, n_students=6, n_days=10, calls_per_day=3,
n_sims=400, **course_kw):
course = models.Course(
lti_context_id="ctx-1", n_sims=n_sims, sim_seed=1, **course_kw
)
db.add(course)
db.flush()
models.ensure_default_levels(db, course)
levels = {
lvl.label: lvl for lvl in models.assessment_levels(db, course.id)
}
students = []
for i in range(n_students):
s = models.Student(canvas_user_id=f"u{i}", name=f"Student {i}")
db.add(s)
students.append(s)
db.flush()
for s in students:
db.add(models.Enrollment(course_id=course.id, student_id=s.id))
db.flush()
# Round-robin answered calls, all GOOD, over n_days class days.
days = [D0 + datetime.timedelta(days=i) for i in range(n_days)]
idx = 0
for day in days:
for _ in range(calls_per_day):
db.add(
models.Call(
course_id=course.id,
student_id=students[idx % n_students].id,
session_date=day,
status=models.STATUS_ANSWERED,
assessment_id=levels["GOOD"].id,
)
)
idx += 1
db.flush()
return course, students, days, levels
def test_all_present_all_good_grades_full(db_session):
course, students, days, levels = setup_course(db_session)
results = grading.compute(db_session, course)
assert results["curve"][0] == 0.0
for row in results["students"]:
assert row["q_points"] == 100.0
assert row["days_missed"] == 0
assert row["final_points"] == 100.0
def test_foregone_curve_is_nonnegative_and_grows(db_session):
course, *_ = setup_course(db_session)
results = grading.compute(db_session, course)
curve = results["curve"]
assert all(v >= 0 for v in curve)
# Missing most days must cost more than missing one.
assert curve[8] > curve[1]
def test_zero_answer_student_floors_to_zero(db_session):
course, students, days, levels = setup_course(db_session)
extra = models.Student(canvas_user_id="u-silent", name="Silent Student")
db_session.add(extra)
db_session.flush()
db_session.add(
models.Enrollment(course_id=course.id, student_id=extra.id)
)
db_session.flush()
results = grading.compute(db_session, course)
row = next(
r for r in results["students"] if r["student_id"] == extra.id
)
assert row["q_points"] is None
assert row["final_points"] == 0.0
def test_luck_protection_present_but_rarely_called(db_session):
"""A student who is always available but was called only once is
not penalized: quality is their grade."""
course, students, days, levels = setup_course(db_session)
lucky = models.Student(canvas_user_id="u-lucky", name="Lucky Student")
db_session.add(lucky)
db_session.flush()
db_session.add(
models.Enrollment(course_id=course.id, student_id=lucky.id)
)
db_session.add(
models.Call(
course_id=course.id,
student_id=lucky.id,
session_date=days[0],
status=models.STATUS_ANSWERED,
assessment_id=levels["POOR"].id,
)
)
db_session.flush()
results = grading.compute(db_session, course)
row = next(
r for r in results["students"] if r["student_id"] == lucky.id
)
assert row["days_missed"] == 0
assert row["final_points"] == pytest.approx(69.7, abs=0.01)
def test_absences_within_allowance_are_free(db_session):
course, students, days, levels = setup_course(
db_session, allowance_sd_units=5.0
)
# One student opts out of two days.
for day in days[:2]:
db_session.add(
models.OptOut(
course_id=course.id, student_id=students[0].id, date=day
)
)
db_session.flush()
results = grading.compute(db_session, course)
row = next(
r for r in results["students"]
if r["student_id"] == students[0].id
)
assert row["days_missed"] == 2
# Huge allowance: no deduction despite the absences.
assert row["final_points"] == row["q_points"]
def test_heavy_absence_is_deducted(db_session):
course, students, days, levels = setup_course(
db_session, allowance_sd_units=0.25
)
for day in days[:8]:
db_session.add(
models.OptOut(
course_id=course.id, student_id=students[0].id, date=day
)
)
db_session.flush()
results = grading.compute(db_session, course)
row = next(
r for r in results["students"]
if r["student_id"] == students[0].id
)
assert row["final_points"] < row["q_points"]
def test_form_penalty_only_for_uncovered_missing_days(db_session):
course, students, days, levels = setup_course(db_session)
caught_s, covered_s = students[0], students[1]
# Both drawn-but-missing on day 3; only covered_s filed the form.
for s in (caught_s, covered_s):
db_session.add(
models.Call(
course_id=course.id,
student_id=s.id,
session_date=days[3],
status=models.STATUS_MISSING,
)
)
db_session.add(
models.OptOut(
course_id=course.id, student_id=covered_s.id, date=days[3]
)
)
db_session.flush()
results = grading.compute(db_session, course)
rows = {r["student_id"]: r for r in results["students"]}
assert rows[caught_s.id]["caught"] == 1
assert rows[covered_s.id]["caught"] == 0
# Both have the same quality and same availability; the caught
# student ends exactly one form penalty lower.
diff = (rows[covered_s.id]["final_points"]
- rows[caught_s.id]["final_points"])
assert diff == pytest.approx(course.form_penalty_points, abs=0.01)
def test_dropped_students_are_excluded_everywhere(db_session):
course, students, days, levels = setup_course(db_session)
baseline = grading.compute(db_session, course)
dropped = models.Student(canvas_user_id="u-dropped", name="Dropped")
db_session.add(dropped)
db_session.flush()
db_session.add(
models.Enrollment(
course_id=course.id, student_id=dropped.id, active=False
)
)
for day in days[:3]:
db_session.add(
models.Call(
course_id=course.id,
student_id=dropped.id,
session_date=day,
status=models.STATUS_ANSWERED,
assessment_id=levels["GOOD"].id,
)
)
db_session.flush()
results = grading.compute(db_session, course)
assert results["excluded_students"] == 1
assert results["excluded_calls"] == 3
assert results["n_students"] == baseline["n_students"]
assert dropped.id not in {r["student_id"] for r in results["students"]}
# Same seed, same effective inputs: identical simulation results.
assert results["sd_full"] == baseline["sd_full"]
assert results["curve"] == baseline["curve"]
def test_scales():
course = models.Course(lti_context_id="x", scale_type="uw4")
assert scales.display(course, 100.0) == "4.00"
assert scales.display(course, 65.15) == "1.70"
assert scales.display(course, 84.85) == "3.00"
assert scales.display(course, 0.0) == "0.00"
course.scale_type = "table"
course.scale_config = '[[93, "A"], [90, "A-"], [0, "F"]]'
assert scales.display(course, 95) == "A"
assert scales.display(course, 91.2) == "A-"
assert scales.display(course, 10) == "F"
course.scale_type = "none"
assert scales.display(course, 87.5) == "87.5"
course.scale_type = "uw4"
assert scales.ags_score(course, 84.85) == (3.0, 4.0)
course.scale_type = "table"
assert scales.ags_score(course, 84.85) == (84.85, 100.0)