Repository navigation
Grading Algorithms
Exact formulas. The teacher-facing version with worked examples is How Grading Works.
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 }.
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 |
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.
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.
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_idclause means only submissions the student attributed to this class count. A one-time migration backfilledclass_idfor 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).
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.
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.
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."
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.
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.
computed_phase = 2 once the student has earned every credential in wbl_phase2_prereqs, else 1.
effectivePhase = COALESCE(override_phase, computed_phase).
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_nowsrow that day → 0 (counts for all three dispositional codes). - Do Now exists but this code isn't among its
wbl_do_now_skillspicks → excluded (not a data point). - This code was picked, and a
wbl_exit_slipsrow exists:- voided (
wbl_exit_slip_voids) → 0 - no verification row → excluded (student-claimed, not yet reviewed)
- latest verification has
rating(0–4) →rating × 25
- voided (
- Picked but no Exit Slip row → 0.
- No
- Score = average of the values that counted. No countable days ⇒ blank.
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.
| 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.
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.
- 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. - Bucket by
(category, normalizedStudent)→weekStart → { sum, n }. Students no longer on the roster are dropped; ratings from weeks afterwkStartare ignored. - 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
scoresarray → 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 flaggediscountedinfinalgrade: false. Six assignments per class: 3 categories ×{ weekly, standing }. - Cadence-agnostic: a
daily,weekly, orweekly_logclass all produce the identical payload shape. Daily evidence-log entries are never part of the score.