Skip to content

Commit 11056e0

Browse files
author
Nils Bars
committed
Replace single scoring policy with per-task scoring policies
ExerciseConfig now carries a per_task_scoring_policies JSON column keyed by submission-test task name instead of a single scoring_policy dict. Task names are AST-discovered from each exercise's submission_tests file, so the config editor shows exactly the tasks registered by the test script. Submissions are scored with score_submission(), which applies each task's policy independently and returns (total, per-task breakdown); the scoreboard API exposes the breakdown to the SPA. The public scoreboard drops the user-selectable ranking strategy: the SCOREBOARD_RANKING_MODE setting and RANKING_STRATEGIES registry are removed, and the SPA is hardwired to the best-sum strategy. The chart samples the timeline at real event timestamps only, so every plotted point corresponds to an actual submission or window edge.
1 parent e2ca935 commit 11056e0

27 files changed

Lines changed: 1374 additions & 698 deletions

.claude/CLAUDE.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,13 @@ The test infrastructure (`tests/helpers/ref_instance.py`) automatically sets thi
2323
# Build all Docker images
2424
./ctrl.sh build
2525

26-
# Start services (--debug attaches to terminal with logs)
27-
./ctrl.sh up --debug
28-
./ctrl.sh up
26+
# Start services
27+
# For development, always use --debug and --hot-reloading:
28+
# --debug enables Flask debug mode and verbose logging
29+
# --hot-reloading enables Flask auto-reload and runs the spa-frontend
30+
# under `vite dev` (Vite HMR) instead of a static build
31+
./ctrl.sh up --debug --hot-reloading
32+
./ctrl.sh up # production-style start, no HMR
2933

3034
# Stop services
3135
./ctrl.sh stop # Keep containers

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,4 @@ The following features are disabled by default and can be enabled from the admin
184184
Allows students to be organized into named groups with a configurable maximum size. Students pick a group during registration, and admins can manage the available groups and reassign students afterwards. Enable via the `GROUPS_ENABLED` setting and configure the per-group capacity via `GROUP_SIZE`.
185185

186186
#### Scoreboard
187-
A public leaderboard at `/v2/scoreboard` that ranks students based on their exercise submissions. Exercises can be grouped into assignments and the ranking strategy is selected via `SCOREBOARD_RANKING_MODE`. Enable via `SCOREBOARD_ENABLED`; optionally set `LANDING_PAGE` to `scoreboard` to use it as the default landing page.
187+
A public leaderboard at `/v2/scoreboard` that ranks students based on their exercise submissions using a Formula 1 style, time-weighted strategy. Exercises can be grouped into assignments. Enable via `SCOREBOARD_ENABLED`; optionally set `LANDING_PAGE` to `scoreboard` to use it as the default landing page.

docs/SCOREBOARD.md

Lines changed: 57 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
A public leaderboard at `/v2/scoreboard` that ranks students/teams based
44
on submission scores. Exercises are grouped into **assignments**
55
(time-boxed rounds, one per `ExerciseConfig.category`). Each exercise
6-
has a **scoring policy** that transforms raw submission scores into
7-
scoreboard points. The Vue SPA fetches metadata + submissions via two
8-
JSON endpoints and renders rankings, badges, charts, and per-challenge
9-
plots client-side.
6+
has **per-task scoring policies** that transform the raw score of each
7+
submission-test task into scoreboard points; the submission's total is
8+
the sum of the transformed per-task scores. The Vue SPA fetches metadata
9+
+ submissions via two JSON endpoints and renders rankings, badges,
10+
charts, and per-challenge plots client-side.
1011

1112
## Data Model
1213

@@ -22,7 +23,7 @@ class ExerciseConfig(db.Model):
2223
id: Mapped[int] # PK
2324
short_name: Mapped[str] # unique
2425
category: Mapped[Optional[str]] # assignment label
25-
scoring_policy: Mapped[Optional[dict]] # JSON, see below
26+
per_task_scoring_policies: Mapped[Optional[dict]] # JSON: {task_name: policy}
2627
submission_deadline_start: Mapped[Optional[datetime]]
2728
submission_deadline_end: Mapped[Optional[datetime]]
2829
submission_test_enabled: Mapped[bool]
@@ -43,12 +44,21 @@ retroactively without reprocessing stored data.
4344

4445
## Scoring Policies
4546

46-
The `scoring_policy` column on `ExerciseConfig` is a JSON object the
47-
admin edits from the exercise config page. `ref/core/scoring.py` exposes
48-
`apply_scoring(raw, policy)` which every API call routes raw scores
49-
through.
47+
`ExerciseConfig.per_task_scoring_policies` is a JSON object keyed by
48+
submission-test task name, where each value is a policy dict. The admin
49+
edits it from the exercise config page; task names are auto-discovered
50+
from the exercise's `submission_tests` file via AST parsing
51+
(`ref/core/task_discovery.py::extract_task_names_from_submission_tests`),
52+
so the editor always shows exactly the tasks the test script registers.
5053

51-
Supported modes:
54+
`ref/core/scoring.py::score_submission(results, per_task_policies)`
55+
applies each task's policy (or pass-through if the task has no entry)
56+
to that task's raw score and returns `(total, breakdown)` where
57+
`breakdown[task_name]` is the transformed score (or `None` for tasks
58+
whose raw score was `None`). `total` sums the transformed scores;
59+
`None`-scored tasks contribute 0.
60+
61+
Supported policy modes (same shape as `apply_scoring(raw, policy)`):
5262

5363
```
5464
# Linear mapping: raw [min_raw..max_raw] → [0..max_points]
@@ -71,29 +81,19 @@ tiers:
7181
```
7282

7383
Any policy may also carry an optional `baseline` field. It has no effect
74-
on the transformed score; the SPA renders it as a horizontal reference
75-
line on per-challenge plots (typically the score of a naive/trivial
76-
solution).
84+
on the transformed score; the SPA renders the **sum of per-task
85+
baselines** as a horizontal reference line on per-challenge plots
86+
(typically the score of a naive/trivial solution).
7787

7888
`validate_scoring_policy(policy)` in the same module returns a list of
79-
human-readable error strings — the exercise-config edit view uses this
80-
to surface admin mistakes before persisting.
81-
82-
## Ranking Strategies
89+
human-readable error strings for a single policy dict — the exercise-
90+
config edit view validates each per-task entry with it before persisting.
8391

84-
Ranking strategies are registered in `RANKING_STRATEGIES` in
85-
`ref/core/scoring.py`. The active strategy is chosen by the
86-
`SCOREBOARD_RANKING_MODE` system setting and surfaced to the SPA via the
87-
config endpoint. Each strategy has a matching TypeScript module under
88-
`spa-frontend/src/ranking/` that computes the ranking client-side.
92+
## Ranking Strategy
8993

90-
| Id | Label | Source |
91-
|----|-------|--------|
92-
| `f1_time_weighted` | Formula 1 (time-weighted) | `spa-frontend/src/ranking/f1_time_weighted.ts` |
93-
| `best_sum` | Sum of best per challenge | `spa-frontend/src/ranking/best_sum.ts` |
94-
95-
Adding a strategy is one dict entry on the Python side plus one `.ts`
96-
file on the frontend.
94+
Ranking is computed client-side by
95+
`spa-frontend/src/ranking/f1_time_weighted.ts` — a Formula 1 style,
96+
time-weighted strategy. It is the only strategy the scoreboard supports.
9797

9898
## API Endpoints
9999

@@ -103,46 +103,59 @@ feature never leaks its existence). No authentication required.
103103

104104
### `GET /api/scoreboard/config`
105105

106-
Assignment/challenge metadata plus the active ranking strategy.
106+
Assignment/challenge metadata.
107107

108108
```json
109109
{
110110
"course_name": "OS-Security",
111-
"ranking_mode": "f1_time_weighted",
112111
"assignments": {
113112
"Assignment 1": {
114113
"exercise_short_name": {
115114
"start": "DD/MM/YYYY HH:MM:SS",
116115
"end": "DD/MM/YYYY HH:MM:SS",
117-
"scoring": { "mode": "threshold", "threshold": 0.5, "points": 100, "baseline": 0.013 },
118-
"max_points": 100
116+
"per_task_scoring_policies": {
117+
"coverage": { "mode": "linear", "max_points": 100, "baseline": 0.013 },
118+
"crashes": { "mode": "threshold", "threshold": 1, "points": 50 }
119+
},
120+
"max_points": 150
119121
}
120122
}
121123
}
122124
}
123125
```
124126

125-
Only exercises whose default version has finished building and whose
126-
`ExerciseConfig` has both deadline endpoints + a non-null `category` are
127-
included. Empty assignment buckets are pruned.
127+
`max_points` is the best-effort sum of each per-task policy's upper
128+
bound (used by the frontend for axis scaling); it is `null` if no task
129+
has a computable maximum. Only exercises whose default version has
130+
finished building and whose `ExerciseConfig` has both deadline endpoints
131+
+ a non-null `category` are included. Empty assignment buckets are
132+
pruned.
128133

129134
### `GET /api/scoreboard/submissions`
130135

131-
Submission scores grouped by exercise and team, pre-transformed by
132-
`apply_scoring()`:
136+
Submission scores grouped by exercise and team, pre-transformed via
137+
`score_submission()` with a per-task breakdown:
133138

134139
```json
135140
{
136141
"exercise_short_name": {
137-
"Team A": [["DD/MM/YYYY HH:MM:SS", 87.5], ...]
142+
"Team A": [
143+
{
144+
"ts": "DD/MM/YYYY HH:MM:SS",
145+
"score": 87.5,
146+
"tasks": { "coverage": 50.0, "crashes": 37.5, "env_check": null }
147+
}
148+
]
138149
}
139150
}
140151
```
141152

142-
Submissions with zero or multiple test results are skipped and logged;
143-
the endpoint expects exactly one top-level test result per submission.
144-
The team label comes from `team_identity(user)`, which returns the
145-
user's group name when groups are enabled, otherwise their full name.
153+
`tasks` values of `null` mean the underlying `SubmissionTestResult.score`
154+
was `None` (bool-returning test, no grading) — consumers render these
155+
as "untested" rather than 0. Such tasks contribute 0 to the outer
156+
`score`. Submissions with no test results at all are skipped. The team
157+
label comes from `team_identity(user)`, which returns the user's group
158+
name when groups are enabled, otherwise their full name.
146159

147160
## Frontend
148161

@@ -172,8 +185,7 @@ dedicated backend. Badge assets are static SVG files at
172185
| Setting | Type | Purpose |
173186
|---------|------|---------|
174187
| `SCOREBOARD_ENABLED` | bool | Master toggle for the page + JSON endpoints |
175-
| `SCOREBOARD_RANKING_MODE` | str | Selected ranking strategy id |
176188
| `LANDING_PAGE` | str | `"registration"` or `"scoreboard"` — where `/` redirects |
177189

178-
All three are exposed in the admin system-settings form
190+
Both are exposed in the admin system-settings form
179191
(`webapp/ref/view/system_settings.py`).

spa-frontend/src/api/scoreboard.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,35 @@
11
import { apiGet } from './client';
22

3+
// Policy shape mirrors ref/core/scoring.py::apply_scoring inputs.
4+
export type ScoringPolicy = Record<string, unknown> & { baseline?: number };
5+
36
// Mirrors /api/scoreboard/config response shape.
47
export interface ChallengeCfg {
58
start: string;
69
end: string;
7-
scoring: Record<string, unknown> & { baseline?: number };
10+
per_task_scoring_policies: Record<string, ScoringPolicy>;
811
max_points: number | null;
912
}
1013

1114
export type Assignments = Record<string, Record<string, ChallengeCfg>>;
1215

1316
export interface ScoreboardConfig {
1417
course_name: string;
15-
ranking_mode: string;
1618
assignments: Assignments;
1719
}
1820

19-
// Submissions: challenge -> team -> [[tsStr, score], ...]
20-
export type TeamSubmissions = Record<string, Array<[string, number]>>;
21+
// One submission entry as returned by /api/scoreboard/submissions.
22+
// `tasks` maps task_name -> transformed score; `null` means the task's
23+
// raw score was None (bool-returning test) and should be rendered as
24+
// "untested" rather than zero.
25+
export interface SubmissionEntry {
26+
ts: string;
27+
score: number;
28+
tasks: Record<string, number | null>;
29+
}
30+
31+
// Submissions: challenge -> team -> SubmissionEntry[]
32+
export type TeamSubmissions = Record<string, SubmissionEntry[]>;
2133
export type SubmissionsByChallenge = Record<string, TeamSubmissions>;
2234

2335
export function getScoreboardConfig(): Promise<ScoreboardConfig> {

spa-frontend/src/components/scoreboard/ChallengePlot.vue

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,27 +18,49 @@ let chart: Chart | null = null;
1818
let xMinCache = 0;
1919
2020
function findBaseline(): number | null {
21+
// The plot's baseline line is drawn at the sum of per-task baselines
22+
// (each task's policy may optionally carry one). Returns null if no
23+
// task has a baseline configured.
2124
for (const challenges of Object.values(props.assignments || {})) {
2225
const cfg = challenges[props.challengeName];
23-
if (cfg && cfg.scoring && typeof cfg.scoring.baseline === 'number') {
24-
return cfg.scoring.baseline;
26+
if (!cfg || !cfg.per_task_scoring_policies) continue;
27+
let total = 0;
28+
let any = false;
29+
for (const policy of Object.values(cfg.per_task_scoring_policies)) {
30+
if (policy && typeof policy.baseline === 'number') {
31+
total += policy.baseline;
32+
any = true;
33+
}
2534
}
35+
if (any) return total;
2636
}
2737
return null;
2838
}
2939
40+
type PlotPoint = {
41+
x: number;
42+
y: number;
43+
tasks: Record<string, number | null>;
44+
};
45+
3046
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3147
function buildDatasets(): any[] {
3248
const teams = (props.submissions && props.submissions[props.challengeName]) || {};
3349
return Object.entries(teams).map(([team, points]) => {
3450
const parsed = points
35-
.map(([tsStr, score]) => {
36-
const d = parseApiDate(tsStr);
37-
return d ? { x: d.getTime(), y: Number(score) } : null;
51+
.map((entry) => {
52+
const d = parseApiDate(entry.ts);
53+
return d
54+
? {
55+
x: d.getTime(),
56+
y: Number(entry.score),
57+
tasks: entry.tasks ?? {},
58+
}
59+
: null;
3860
})
39-
.filter((p): p is { x: number; y: number } => p !== null)
61+
.filter((p): p is PlotPoint => p !== null)
4062
.sort((a, b) => a.x - b.x);
41-
const improvements: { x: number; y: number }[] = [];
63+
const improvements: PlotPoint[] = [];
4264
let best = -Infinity;
4365
for (const p of parsed) {
4466
if (p.y > best) {
@@ -105,6 +127,27 @@ function render() {
105127
annotation: { annotations },
106128
legend: { labels: { usePointStyle: true } },
107129
zoom: makeZoomPanOptions(() => xMinCache),
130+
tooltip: {
131+
callbacks: {
132+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
133+
label: (ctx: any) =>
134+
`${ctx.dataset.label ?? ''}: ${Number(ctx.parsed.y).toFixed(2)}`,
135+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
136+
afterBody: (items: any[]) => {
137+
if (!items.length) return [];
138+
const raw = items[0].raw as PlotPoint | undefined;
139+
const tasks = raw?.tasks;
140+
if (!tasks || Object.keys(tasks).length < 2) return [];
141+
const lines = ['', 'Tasks:'];
142+
for (const [name, score] of Object.entries(tasks)) {
143+
const rendered =
144+
score === null ? 'untested' : Number(score).toFixed(2);
145+
lines.push(` ${name}: ${rendered}`);
146+
}
147+
return lines;
148+
},
149+
},
150+
},
108151
},
109152
},
110153
});

spa-frontend/src/components/scoreboard/PointsOverTimeChart.vue

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,17 @@ function buildAnnotations(): Record<string, any> {
4747
borderWidth: 1,
4848
scaleID: 'x',
4949
value: t.getTime(),
50-
label: { content: `Assignment ${i + 1}`, display: true },
50+
label: {
51+
content: `Assignment ${i + 1}`,
52+
display: true,
53+
rotation: -90,
54+
position: 'center',
55+
xAdjust: 12,
56+
yAdjust: -10,
57+
backgroundColor: 'rgba(0, 0, 0, 0)',
58+
color: 'gray',
59+
padding: 0,
60+
},
5161
};
5262
});
5363
return annotations;

spa-frontend/src/components/scoreboard/RankingTable.vue

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22
import type { Ranking } from '../../ranking/types';
33
import type { Badges } from '../../ranking/util';
44
5-
defineProps<{
6-
ranking: Ranking;
7-
badges: Badges;
8-
}>();
5+
const props = withDefaults(
6+
defineProps<{
7+
ranking: Ranking;
8+
badges?: Badges;
9+
hideBadges?: boolean;
10+
}>(),
11+
{ badges: () => ({}), hideBadges: false },
12+
);
913
1014
function badgeSrc(name: string): string {
1115
return `/static/badges/${name}.svg`;
@@ -16,6 +20,8 @@ function onBadgeError(e: Event) {
1620
img.onerror = null;
1721
img.src = '/static/badges/default.svg';
1822
}
23+
24+
const colSpan = props.hideBadges ? 3 : 4;
1925
</script>
2026

2127
<template>
@@ -25,18 +31,18 @@ function onBadgeError(e: Event) {
2531
<tr>
2632
<th class="term-col-rank">#</th>
2733
<th>Team</th>
28-
<th>Badges</th>
34+
<th v-if="!hideBadges">Badges</th>
2935
<th class="term-col-points">Points</th>
3036
</tr>
3137
</thead>
3238
<tbody>
3339
<tr v-if="!ranking || ranking.length === 0" class="term-empty">
34-
<td colspan="4">// awaiting submissions</td>
40+
<td :colspan="colSpan">// awaiting submissions</td>
3541
</tr>
3642
<tr v-for="([team, score], i) in ranking" :key="team">
3743
<td class="term-col-rank"><span class="term-rank">{{ i + 1 }}</span></td>
3844
<td class="term-team">{{ team }}</td>
39-
<td>
45+
<td v-if="!hideBadges">
4046
<span class="term-badges">
4147
<img
4248
v-for="b in badges[team] || []"

0 commit comments

Comments
 (0)