Skip to content

Grading Algorithms

benjamindobbs edited this page Oct 1, 2026 · 6 revisions

Grading Algorithms

Exact formulas. The teacher-facing version with worked examples is How Grading Works.


KenKen: per-game score

js/kenken.js, on completion:

unsolved = size*size - hintsGiven
score    = round( ( unsolved / guesses  +  2.5 * unsolved / time ) * size * 10 )
score    = min( score, round( nextRankThreshold * 1.3 ) )
  • size — grid side length (floor(rank) + 2, capped at 9).
  • guesses — number entries the player made.
  • time — seconds elapsed.
  • The cap keeps one lucky puzzle from vaulting a student past a rank.

Posted to POST /api/kenken/score as { score, size, class_id }.

KenKen: rank

server/rankTable.js. LEVEL_STARTS = [0, 50, 84, 116, 144, 168, 188] (must match js/kenken.js).

avg   = AVG(score) over all of the student's kenken_scores    # all-time, all classes
level = highest i where avg >= LEVEL_STARTS[i-1]              # 1..7
# interpolate within the level:
start = LEVEL_STARTS[level-1]; end = LEVEL_STARTS[level]
rank  = level + clamp( (avg - start) / (end - start), 0, 1 )

grid = floor(rank) + 2 (max 9). Sub-rank (fractional part) sets hint count:

Sub-rank Hints
0.0–0.1 floor(cells / 3)
0.1–0.3 floor(cells / 4)
0.3–0.5 floor(cells / 5)
0.5–0.8 floor(cells / 6)
0.8–1.0 floor(cells / 7)
rank ≥ 7 0

Daily-practice grade (GET /api/teacher/grades)

server/routes/teacher.js. Inputs: class_id, start, end. Graded one day at a time, then averaged — required_kenken_count/required_sat_count/ required_sat_math_count are per-day counts (same ones /daily-progress checks against "today"), so a multi-day window is a day-by-day audit, not a single lump-sum ratio over the whole range.

Per class

settings   = gradebook_settings[teacher]            # or {100, 100, 0}
MAX        = settings.assignment_max_score
NOSUB      = round(MAX * settings.no_submission_score_pct / 100)
COMPLETION = round(MAX * settings.completion_score_pct / 100)

meetDates  = SELECT DISTINCT date FROM wbl_attendance
             WHERE class_id = ? AND date BETWEEN start AND end

If attendance has been pulled for the class, meetDates is the real meeting-day calendar (and also the source of per-student attendance codes — see Absences below). A student whose own slice of meetDates is empty (no attendance pulled at all, or a narrow enrolled slice with no rows in it) falls back to every calendar day in their own enrolled window instead.

Per student

effStart = max(start, enrolled_on || start)
effEnd   = min(end,   exited_on   || end)
if effStart > effEnd:  → { grade: null, exempt: true }   # no score row emitted

if !user_key:  → { grade: NOSUB, unlinked: true }        # not linked to an app account

studentDates = meetDates ∩ [effStart, effEnd]
             , or every calendar day in [effStart, effEnd] if that intersection is empty

exemptDates  = excusedAbsenceDates(class, student, effStart, effEnd)   # see Absences below
countedDates = studentDates − exemptDates
if countedDates is empty:  → { grade: null, exempt: true }   # every day in range was excused

For each date in countedDates, bucketed by day (UTC midnight — same boundary /daily-progress uses for "today"):

threshold = AVG(score) over ALL of the student's kenken_scores      # all-time, all classes
k(date)   = COUNT kenken_scores    WHERE user_key AND that day AND score >= threshold AND class_id = :class_id
s(date)   = COUNT sat_scores       WHERE user_key AND that day AND correct = 1        AND class_id = :class_id
m(date)   = COUNT sat_math_scores  WHERE user_key AND that day AND correct = 1        AND class_id = :class_id
r(date)   = COUNT measurement_scores WHERE user_key AND that day AND correct = 1      AND class_id = :class_id
p(date)   = COUNT projection_scores  WHERE user_key AND that day AND correct = 1      AND class_id = :class_id

Note: the class_id = :class_id clause means only submissions the student attributed to this class count. A one-time migration backfilled class_id for students in exactly one class.

SAT accuracy mode (classes.sat_requirement_mode = 'accuracy'): for sat / sat-math, req_<a> is still the correct count (C), but s(date) / m(date) are replaced by C × accuracyProgress(answers, C, sat_accuracy_window, sat_accuracy_pct).fraction, where answers is that day's full 0/1 sequence for the class, ordered by submitted_at, id. accuracyProgress (server/satAccuracy.js, shared with /daily-progress) walks the day answer by answer:

W, target = sat_accuracy_window, sat_accuracy_pct / 100
for each answer i:
    correct_i = right answers so far
    acc_i     = right answers among the last min(i+1, W) / min(i+1, W)
    met      |= correct_i >= C and acc_i >= target
    fraction  = max(fraction, min(1, correct_i / C) * min(1, acc_i / target))
fraction = met ? 1 : min(fraction, 0.999)                 # 0 if no answers

An unmet day always takes the partial branch below. The *_count totals in the response stay correct-answer counts in either mode.

SAT answers submitted less than MIN_ANSWER_MS (10 s) after /next served the question are discarded by /score before they're written (server/satPace.js, sat_serves), so they never reach any of the queries above.

ra  = classes.required_activity          # parsed by js/activities.js
set = Activities.parse(ra)               # e.g. {kenken, measurement}; legacy names map to sets

if ra is 'either' (or empty):
    dayRequired = max(req_kenken, req_sat) ; dayActual = max(k(date), s(date))
else:
    dayRequired = Σ req_<a>   for a in set  # required_kenken/sat/sat_math/measurement/projection_count
    dayActual   = Σ <a>(date) for a in set  # k / s / m / r / p above

if dayActual == 0:
    dayGrade = NOSUB
elif dayActual < dayRequired:
    dayGrade = max( round(dayActual / dayRequired * MAX), COMPLETION )   # partial day, floored
else:
    dayGrade = MAX                                                       # met/exceeded, full credit
dayGrade = min(dayGrade, MAX)
grade = min(MAX, round( mean(dayGrade for each date in countedDates) ))

kenken_count/sat_count/sat_math_count/measurement_count/projection_count in the response are informational sums across countedDates (display only — not part of the grade math).

Absences

excusedAbsenceDates(class, student, from, to) (wbl/logic.js) drops a date from countedDates entirely — dropped, not zero-filled — based on that day's wbl_attendance.code:

Code Effect
Starts EX, is not EXT (e.g. EX-ILL, EX-MA) Excused — day dropped
EXT (tardy, despite the EX prefix) Not excused — graded normally
UNV, with a matching wbl_called_outs row and the class linked to a non-archived WBL program Excused — day dropped
UNV, otherwise (no called-out row, or the class isn't WBL-linked) Not excused — counts against the student like any zero day
anything else (blank = present, UXT, …) Not excused

Requires PS attendance to have been pulled for the class — see The DobbsCore Extension. The "Sync Attendance" pull is not WBL-gated, so a non-WBL class can have this data too; it just never gets the UNV called-out exception since that override only exists on a linked WBL program's roster.

Timezone note

The per-day bucket is UTC-midnight-to-UTC-midnight (strftime('%Y-%m-%d', submitted_at/1000, 'unixepoch')), matching the /daily-progress pill's "today" boundary. The outer window bound (effStart 00:00–effEnd 23:59) is parsed as local midnight, so a submission in the last few hours before/after local midnight on the very first or last day of a pushed window can fall just outside the query's bounding box — in practice this only matters at the extreme edges of a window.


Work-Based Learning

Attendance ratio (wbl/logic.js → attendanceRatio)

Per meeting day in [from, to] for (program, class, student):

wbl_attendance.code Contribution
UNV with a matching wbl_called_outs row dropped — neither credit nor a counted day
UNV (no called-out) 0 credit, 1 counted day
UXT 0.5 credit, 1 counted day
anything else (blank = present, EXT, …) 1 credit, 1 counted day

ratio = credit / counted. Returns null (not 0) when there are no attendance rows in range — callers use that to mean "nothing pulled yet."

Work Event score

Per participant with a Holistic Call:

tierPct   = wbl_holistic_tier_points[program, call.tier].points_pct    # default 0/50/85/100
att       = attendanceRatio(program, class, student, participant.joined_on, participant.left_on || event.closed_on || today)
points_pct = att ? round(0.8 * tierPct + 0.2 * (att.ratio * 100), 2) : tierPct
score      = round( points_pct / 100 * workEventMaxPoints, 2 )

Participants without a Holistic Call aren't scored and aren't on the PowerSchool assignment.

Credential progress (credentialProgress)

A demonstration of a skill counts only when it is result = 'mastered', on a Work Event whose Holistic Call reached the credential's min_holistic_tier, and demonstrations are counted across distinct Work Events (one job = at most one demonstration per skill). A wbl_skill_check also counts as one demonstration. A skill is satisfied when its demonstrations ≥ required_demonstrations; a credential is earned when every required skill is satisfied.

Phase (effectivePhase, recomputePhase)

computed_phase = 2 once the student has earned every credential in wbl_phase2_prereqs, else 1. effectivePhase = COALESCE(override_phase, computed_phase).

Habits of Work — dispositional (dispositionalScore)

This is the WBL dispositional score. The standalone non-WBL flow is a different calculation — see Habits of Work (non-WBL classes) at the bottom of this page.

For (program, class, student, softSkillCode, from, to) — from/to are the PS marking-period dates, clamped to the student's enrollment span:

  • dates = meetingDates(class, effFrom, effTo). No meeting days ⇒ no score (blank).
  • For each meeting date:
    • No wbl_do_nows row that day → 0 (counts for all three dispositional codes).
    • Do Now exists but this code isn't among its wbl_do_now_skills picks → excluded (not a data point).
    • This code was picked, and a wbl_exit_slips row exists:
      • voided (wbl_exit_slip_voids) → 0
      • no verification row → excluded (student-claimed, not yet reviewed)
      • latest verification has rating (0–4) → rating × 25
    • Picked but no Exit Slip row → 0.
  • Score = average of the values that counted. No countable days ⇒ blank.

Habits of Work — transfer

SUM(score) FROM wbl_transfer_claims WHERE verdict = 'verified' per kind, capped at the shared max points. Only synced once at least one enrolled student in the class has effectivePhase == 2; Phase-1 students on that assignment are left blank.

Sync state → PowerSchool score (content.js, DobbsCore extension)

State Earner Non-earner
not_started — item not synced at all —
in_progress 100 blank (null, no score row)
due 100 0

Auto-promotion: GET /api/wbl/sync/progress lazily flips any not_started credential/skill row to in_progress when hasSkillEvidence finds any mastered assessment or wbl_skill_check for a roster student.


Habits of Work (non-WBL classes)

The standalone Do Now / Exit Slip flow for classes not in a WBL program (server/routes/how.js, GET /api/how/sync). Unrelated to the WBL dispositional score above — no attendance, no phase, no marking-period window; just the teacher's per-category ratings. Teacher-facing: Habits of Work. Internals: Habits of Work Internals.

Inputs: a class_id and a week (any date inside the target ISO week; defaults to today). wkStart = weekStart(week) (that week's Monday), wkEnd = wkStart + 6.

  1. Pull every teacher rating (how_exit_slip_ratings.rating, 1–4) on a non-voided exit slip in the class dated ≤ wkEnd. The category comes from the rating row, so a weekly slip feeds all three category buckets and a daily slip feeds one.
  2. Bucket by (category, normalizedStudent) → weekStart → { sum, n }. Students no longer on the roster are dropped; ratings from weeks after wkStart are ignored.
  3. Per category, per rostered student:
RATING_TO_PCT = 25                              # same rating × 25 mapping as WBL dispositional

weeklyScore   = round2( thisWeek.sum / thisWeek.n * 25 )        # only if the student has a bucket for wkStart

recentWeeks   = last ≤3 week keys ≤ wkStart that have data
standingScore = round2( mean( for each w in recentWeeks: w.sum / w.n * 25 ) )
  • Voided and unrated slips never count. No zero-fill for absences — non-WBL classes pull no attendance.
  • A student with no bucket for a category is simply absent from that category's scores array → no PowerSchool score row (blank).
  • The extension scales each score (0–100) to the assignment's max points and writes it to a PS assignment flagged iscountedinfinalgrade: false. Six assignments per class: 3 categories × { weekly, standing }.
  • Cadence-agnostic: a daily, weekly, or weekly_log class all produce the identical payload shape. Daily evidence-log entries are never part of the score.

Clone this wiki locally