Semi-automatic grading for Jupyter Notebook assignments in MUSA 5500: Geospatial Data Science in Python. The guiding principle: automate what is objectively testable, surface ambiguity, and make human review fast. Every deduction traces back to a deterministic test, a structural check, a notebook result, or an explicit human judgement.
Grading principle: The grader is a screening tool, not a scoring tool. It can only clear a submission, never mark one down: any submission that does not come back at full marks is read by a person, and the deduction stands or falls on that reading. Efficiency comes from the submissions the grader clears, not from trusting it where it is uncertain.
- Grading runs entirely on the instructor's or TA's machine; submissions are never uploaded anywhere.
- The Streamlit UI binds to localhost, and notebooks execute in a Docker container with no network access.
- Failure pattern is reproduced in tests/ with synthetic fixtures, and no student file, name, or identifier appears in this repository for model calibration only.
- We only used LLM to assist build this tool, but no student assignments were upload to LLM.
The dataset decides what can be checked, and the three assignments hand the grader three different situations:
| Assignment 1 — The Donut Effect | Assignment 2 — Exploratory Visualization | Assignment 3 — Evictions and the NDVI | |
|---|---|---|---|
| Dataset | One Zillow file, downloaded by each student | The student's own choice, never handed in | One zip, distributed with the assignment |
| Is there an answer key? | No — Zillow revises the file monthly, so counts and percentages differ legitimately | No — every student explores a different dataset | Yes — everyone runs the same five files |
| What is graded | Whether each step worked, against the student's own data | Whether the student can produce the required elements | Whether each result is the right one |
| Where the evidence comes from | The grading run's own execution | The outputs the student saved in the notebook | Both — the answers the notebook shows, confirmed by a re-run when one is possible |
Students download their own Zillow ZHVI extract, and Zillow revises and extends that file every month. Row counts, ZIP counts and final percentages therefore differ legitimately between submissions, so the grader never compares against a stored answer. It asks whether each step in the instructions worked, and verifies it against the student's own data:
| Instruction | How it is checked |
|---|---|
| Load the data | pandas import, a read_* call, a relative path, a wide frame with many month columns |
| Trim to Philadelphia | every ZIP in the subset is a Philadelphia ZIP, and City/State hold no other place |
| Melt to tidy | rows equal this student's own (unique ZIPs x unique dates), values in a column named ZHVI |
| Split Center City | the two frames partition this student's own Philadelphia ZIP set, using the template's ZIP list |
| Percent increase function | called on a frame the grader builds, so the right answer is known whatever data was loaded |
| Compare the two groups | both averages exist, are plausible, and run in the Donut Effect direction |
The only fixed values in rubrics/hw1.yaml are the ones the assignment itself
fixes: the Center City ZIP list printed in the student template, and the two
comparison dates (2020-03-31 and 2022-03-31) named in the instructions.
A regression test proves the point: the same notebook graded against a shorter, smaller, rescaled ZHVI file still scores 100/100.
Students pick their own dataset and hand in the notebook alone. The grader has
no copy of the data, so it cannot re-run anything: every submission dies at
read_csv. Nothing about the content of a chart was ever checkable, and this
assignment does not ask it to be — it asks whether the student can produce the
required elements.
So the rubric splits down the middle of the assignment text.
Facts, graded automatically (70 points). An Altair chart compiles to a
Vega-Lite specification, and that specification sits in the notebook's own saved
output. A mean/count/bin transformation, an interval brush and a
transform_filter() cross-filter are read out of it rather than guessed from
source text — which is what lets the grader tell apart the three things students
most often confuse:
| Student wrote | Vega-Lite says | Verdict |
|---|---|---|
alt.selection_interval() + add_params |
params: [{select: {type: interval}}] |
brush ✓ |
.interactive() |
the same, plus bind: "scales" |
pan/zoom, not a brush |
alt.selection_point() |
select: {type: point} |
a selection, half credit |
transform_filter("datum.x > 5") |
filter with no param |
a subset, not a transformation |
transform_filter(brush) across hconcat |
filter: {param: …} + two views |
cross-filter ✓ extra credit |
Judgement, graded by a person (30 points). The assignment marks the matplotlib
chart on "colour choices and clarity" and asks for written reasoning under each
chart. A picture's quality and a paragraph's substance are not machine facts.
Those items come back as manual_review always, whatever they score, carrying
a provisional score built from real signals — labelling and colour calls extracted
from the source, word counts and the position of each markdown cell — plus the
text itself. A TA confirms or adjusts a number instead of starting from a blank
box.
Where the evidence is read from is recorded per chart. When an instructor does supply the dataset, a freshly executed chart takes over from the saved one automatically, and the confidence rises from 0.85 to 0.95.
The extra credit dashboard carries 0 points in the rubric so it never inflates the maximum. When it is found, the item is flagged with the suggested bonus; a manual override on it adds to the student's total without changing what they are graded out of.
Assignment 3 hands out assignment3-data.zip and everyone works from it. That
one difference changes the whole design: for the first time the right answers
exist, so the grader checks results rather than inferring intent.
Philadelphia has 384 census tracts in PA-tracts.geojson. The fifteen listed
violation types cover 34,108 of the 434,052 rows of li_violations.csv. The
tidy eviction frame is 5,376 rows holding 149,472 evictions. The median
NDVI is 0.2025 inside the city limits and 0.3748 in the suburbs, and
2,480 street trees sample to a median of 0.178. None of those is an
opinion about a solution — they are facts about the shipped files, and every one
of them was re-computed under the variations students legitimately produce
(crop=True and crop=False, masked and filled arrays, sjoin(how="inner") and
how="left") before it was written into rubrics/hw3.yaml. Where a step has two
defensible answers — the unstack(fill_value=0).stack() fill the instructions
mark optional gives 5,535 groups instead of 4,000 — both are listed rather than
one being called wrong.
That also changes what confidence means. In Assignment 1 several frames may plausibly be "the Philadelphia subset", and the ambiguity is itself the finding. Here a frame either holds 384 Philadelphia tracts or it does not; if one does, the step demonstrably happened, and which variable holds it does not matter. So a matched expected value scores at confidence 1.0, and confidence drops only where the grader is choosing between readings of a partial answer.
Assignment 1 supplies one data file. Assignment 3 supplies five, and each has to
land at whatever relative path that student happened to write — sometimes built
with os.path.join, sometimes from a variable, sometimes ../data/. When the
placement misses, the notebook dies at the first read_file and every step after
it is invisible: a submission that was entirely correct grades as a column of
zeros, for a reason that is nobody's fault.
The student's own run did not have that problem. They ran it with their data in place and handed in the file with its outputs:
In [3]: len(phl)
Out [3]: 384
That 384 is the answer to 1.1.2, sitting in the file. So is 34108, and
(368606317.0763259, 462551418.69179374), and Median NDVI, city: 0.2025. So is
:HoloMap [year] — the repr of the chart in 1.1.5, which states whether
dynamic=False was passed. Even the numbers a chart plots survive: bokeh writes
each column into the notebook as a base64 buffer, so the fourteen yearly totals
are still there to be summed.
So grader/answers.py reads them, and every item is graded on two independent
readings: what the submitted notebook shows, and what this grading run
produced. Neither is reliably better — a re-run can die on a path, a saved output
can be stale or hand-edited — so they are not ranked, they are combined: a step
counts as done if either source shows the right answer. Execution stops being
load-bearing and becomes corroboration.
| The correct demo submission scores | |
|---|---|
| with all five data files supplied | 99.5 / 100 |
| with no data supplied at all | 99.5 / 100 |
The data is therefore optional in the sidebar. Supply it and 19 of the 22 items come back confirmed by both readings independently; leave it out and the grade comes from the submitted notebook alone, one confidence step lower (0.9 rather than 1.0), which is what a claim about someone else's run is worth.
Two rules keep this honest. A number must be shown to count — the cell values
inside a head() repr are not answers, and harvesting them would invent matches
against a rubric full of exact numbers. And source code alone is never full
marks: writing gpd.sjoin(...) is not evidence that it returned the right thing,
so a step the source plainly performs but never displays earns 70% and a look
from a person.
Two thirds of Part 1 is hvplot, and an hvplot call returns a holoviews object
that knows what it is. The probe reads those objects out of the kernel — from
Out as well as from named variables, because df.hvplot(...) on the last line
of a cell is never bound to a name — and the same structure is in the submitted
notebook as the object's saved repr. Either way the plotting requirements are
graded by their effect:
| The instructions ask for | What the object says (in the kernel, or in the saved repr) | Verdict |
|---|---|---|
groupby="year", dynamic=False |
HoloMap, kdims [year], 14 frames of Polygons |
widget choropleth ✓ |
the same, dynamic=False omitted |
DynamicMap, 0 frames |
nothing renders — named, 60% credit |
| one map per violation type | HoloMap, kdims [violationdescription], 15 frames |
✓ |
| two maps side by side | Layout, shape (1, 2), children [Polygons, Polygons] |
✓ |
| the same, panels never trimmed | Layout whose children are HoloMap |
still carries its widget — 75% |
| the yearly eviction trend | Curve, 14 points summing to 149,472 |
graded on the numbers plotted |
The last row is the point: a chart is not checked for existing, it is checked for plotting the right numbers.
The rubric spends most of its configuration on telling the common near-misses apart, because "wrong" is not useful feedback and a deduction a TA cannot explain is a deduction they have to re-derive:
| What the student did | How it is recognised | Result |
|---|---|---|
points_from_xy(lat, lng) |
the data spans 39.8–40.1 east, not −75.2 | named as a coordinate swap, 30% |
| joined before trimming | the join carries 434,052 rows, not 34,108 | right step, wrong order — 70% |
| joined across two CRSs | the join lost rows, and none should be lost | named, 50% |
| NDVI from bands 3 and 4 | medians come back at −0.02, not 0.20 | off-by-one band index, named, 50% |
np.median on a masked array |
the medians are NaN | named as the NaN trap, 40% |
| suburbs = the envelope | its area is city + suburbs | difference() never applied, 50% |
| tracts left in EPSG:4326 | the polygon's area is 0.037, not 369 km² | named as degrees vs metres, 40% |
One item, worth 3 points. The assignment marks the two street-tree figures on
being "clear and well-styled", and that is a statement about a picture. Labelling
and colour calls are extracted automatically and a provisional score is attached,
capped below full marks, but the item always returns as manual_review. The 1.4
extra credit carries 0 points and is flagged with a suggested bonus, the same way
Assignment 2's dashboard is.
And the standing rule is unchanged: anything short of full marks reaches a person, whatever the rubric decided. The exact numbers above are what lets the grader clear a submission with confidence — not what lets it mark one down unattended.
| Item | Points | |
|---|---|---|
| Notebook executes | 10 | |
| 1.1.1 | Eviction data loaded with geopandas | 3 |
| 1.1.2 | Trimmed to Philadelphia (384 tracts) | 5 |
| 1.1.3 | Melted to tidy format (5,376 rows) | 6 |
| 1.1.4 | Yearly eviction trend with hvplot | 5 |
| 1.1.5 | Year-by-year choropleth with a widget | 5 |
| 1.2.1 | Violations loaded as a point GeoDataFrame | 4 |
| 1.2.2 | Trimmed to the fifteen types (34,108 rows) | 4 |
| 1.2.3 | Hex bin map with tracts overlaid | 4 |
| 1.2.4 | Spatial join to census tracts | 5 |
| 1.2.5 | Counted per type and tract | 5 |
| 1.2.6 | Merged back onto tract geometries | 3 |
| 1.2.7 | Choropleth per violation type with a widget | 5 |
| 1.3 | Evictions and violations side by side | 4 |
| 2.1.1 | Landsat scene opened with rasterio | 3 |
| 2.1.2 | City and suburb polygons | 5 |
| 2.1.3 | Masked, and the NDVI computed | 6 |
| 2.1.4 | Median NDVI compared | 4 |
| 2.2.1 | Street tree data loaded | 3 |
| 2.2.2 | NDVI sampled at the tree locations | 4 |
| 2.2.3 | Histogram and mapped tree points | 4 |
| 2.2.3 | Figure styling — judged by a person | 3 |
| 1.4 | Extra credit — 0 points, flagged, suggested 5 | 0 |
| Total | 100 |
Execution is 10, Part 1 is 58 across thirteen steps, Part 2 is 32 across eight. Part 1 carries more because it is more of the work — thirteen of the twenty steps — and Part 2's items are individually larger because each one is a bigger piece of reasoning. Within each part the weight follows what the step is worth getting right: the melt, the spatial join and the NDVI masking are where the assignment is actually taught, and the loading steps are worth 3 or 4 because they are hard to get wrong and cheap to fix.
pip install -r requirements.txtBuild the grading container once (recommended — student code is untrusted):
docker build -t musa-grader:latest -f docker/Dockerfile .Generate the demo data and example submissions:
python examples/make_example_data.py && python examples/make_example_submissions.pyThat builds seven Assignment 1 submissions covering the cases a TA meets: two correct, one with plausible mistakes, one with two notebooks, one that crashes partway, one with no notebook, and the untouched template. They contain notebooks only — no data — exactly as the class hands work in.
For Assignment 2:
python examples/make_hw2_data.py && python examples/make_hw2_submissions.pyThose four are executed against the data and saved with their outputs, then handed in without it — which is how the class submits, and the only reason there is anything to grade. One does everything including the extra credit, one makes the usual near-misses, one errored in the student's own run, and one was never run at all.
Start the app:
streamlit run app.pyThen, in the sidebar: pick Assignment 1, upload the assignment data file
(for the demo, examples/data/zillow_zhvi.csv), load examples/submissions,
choose an execution mode, and press Run Grader.
Assignment 2 needs no data file at all: pick Assignment 2, load
examples/hw2_submissions, and run. Every notebook will fail to execute, and that
is expected — the rubric grades the outputs the students saved.
For Assignment 3:
python examples/make_hw3_submissions.pyThat builds four submissions — one that does everything including the extra credit, one that makes the near-misses students really make, one that swaps latitude and longitude and crashes partway, and the untouched template — and runs them against the assignment zip so their outputs are saved, which is how the class hands this one in. It needs the geospatial stack installed; without it the notebooks are written empty and it says so.
Then pick Assignment 3, load examples/hw3_submissions, and run. No data
file is needed: the answers are in the outputs each student saved.
python cli.py grade examples/hw3_submissions --rubric rubrics/hw3.yamlScores: 99.5, 68.5, 34.5 and 10 out of 100. The 0.5 the correct one does not get is the styling item's ceiling — no submission reaches full marks on a judgement item without a person.
To have the grader re-run each notebook and confirm what it shows, unzip the data and supply all five files:
unzip assignment_template/assignment3-data.zip -d /tmp/hw3python cli.py grade examples/hw3_submissions --rubric rubrics/hw3.yaml \
--data /tmp/hw3/assignment3-data/PA-tracts.geojson \
--data /tmp/hw3/assignment3-data/li_violations.csv \
--data /tmp/hw3/assignment3-data/landsat8_philly.tif \
--data /tmp/hw3/assignment3-data/City_Limits.geojson \
--data /tmp/hw3/assignment3-data/ppr_tree_canopy_points_2015.geojsonThe correct submission still scores 99.5 — with 19 of its items now confirmed by both readings independently — and the two that only partly worked score higher, because a re-run sees steps whose results they never displayed. The sidebar names any of the five files that are missing, since supplying four of them silently weakens every step that reads the fifth.
For a real class, download the ZHVI extract from Zillow research data — ZHVI All Homes, by ZIP code — and upload that instead. Nothing else changes.
The sidebar takes the file by upload, so it does not have to live anywhere in
particular; uploads are kept in .musa_grader_data/ and survive a restart. If the
file is already on the grading machine, the "or use a file already on this
machine" expander takes a path instead. Either way the files currently in use are
listed with their size and can be removed individually, and the app refuses to
start a run until at least one is set.
A terminal equivalent exists for scripted runs:
python cli.py grade examples/submissions --mode docker --data examples/data/zillow_zhvi.csvTo move the grader to a new machine without setting up Python, build the sandbox image and start the app with Compose — the only requirement is Docker:
docker build -t musa-grader:latest -f docker/Dockerfile .docker compose up -d --buildThen open http://localhost:8501. Sessions land in results/, uploads in
.musa_grader_data/, and anything placed in submissions/ can be loaded from the
UI as submissions. See docker/README.md for how the app
container starts the sandbox containers.
Select Assignment 1
→ upload a Canvas ZIP or point at a submissions folder
→ review what discovery found
→ Run Grader (notebooks execute in isolated containers)
→ class overview: mean, distribution, most common issues
→ review queue: only the submissions that need a decision
→ per-student page: rubric scores, evidence, manual override
→ export grades.csv and per-student feedback
Pages: Overview · Submissions · Grading · Students · Review Queue · Class Analytics · Export · Settings.
Streamlit UI → GradingService → executor → assignment grader → rubric engine → results store
The UI contains no grading logic; it calls grader.service.GradingService and
renders what comes back.
Students hand in a notebook and nothing else, so the grader supplies the data.
Upload the file (or, for Assignment 3, all five files) once — sidebar, or --data
on the CLI — and before each submission runs it places them at:
- every relative path the notebook actually reads from — parsed out of the notebook, including paths held in a variable rather than passed inline, and
- the conventional
data/<name>and<name>locations, for notebooks whose path is built dynamically.
So a notebook reading data/zillow.csv and one reading
data/Zip_zhvi_uc_sfrcondo_tier_0.33_0.67_sm_sa_month.csv both find their file,
with no naming convention imposed on the class. With several supplied files each
target path takes the one whose name is closest to it, so Assignment 3's five
files land where the notebook asks for each of them. The file is hard-linked, not
copied, so fifty submissions cost one copy of a 117 MB extract rather than fifty.
A student's own file is never overwritten, and paths that escape the working
directory (../, absolute, Windows drive letters) are refused.
Everyone is then graded against the same data, which is what makes the class
comparable — and a notebook that hardcodes /Users/me/Desktop/... still fails, as
it should, because that path cannot be supplied.
Notebook filenames are not standardised, so submissions are scored by name hints,
depth and size. When two files look equally plausible the submission is flagged,
not guessed — it reaches the review queue with multiple_notebooks.
Both layouts from the spec are supported: a folder of student folders, and a flat
Canvas export ZIP (doejane_12345_67890_assignment1.ipynb, _late_ included).
Submissions are expected to be notebooks only — the data is supplied by you.
Each submission is copied into its own working directory, the notebook is instrumented with probe cells, and a subprocess executes it — by default inside a container with no network, capped memory and CPU, a read-only root filesystem and no host environment variables (see docker/README.md).
Errors do not stop grading. allow_errors is on, so the notebook runs to the end,
the first failing cell and its traceback are recorded, and every check that can
still run does. A failed notebook never becomes an automatic zero.
Students do not use the solution's variable names, so the probe describes the kernel's namespace rather than looking anything up by name: for every DataFrame it records shape, columns, dtypes, a head sample, numeric summaries, low-cardinality value samples, and — for boolean flag columns — which identifiers they mark.
Assignment 3 put four more object kinds in the namespace, so the probe describes
those too: a GeoDataFrame's CRS, geometry types and total area; a numpy array's
NaN-aware statistics, which is the only way to describe an NDVI array that is
mostly NaN outside the polygon that produced it; shapely geometries and open
rasterio datasets in place; and holoviews objects — their key dimensions, frame
count, layout shape and the values actually plotted — read from Out as well as
from named variables, since a chart on the last line of a cell is never bound to
a name. One summary is deliberately compact: 384 GEOIDs are far past the
value-sample cap, so instead of the list the probe sends a histogram of their
first five characters, which answers "is every row in Philadelphia County?" in a
handful of bytes.
The assignment grader then looks for objects that look like the expected result (right ZIP prefix, right row count, right roles) and reports how many plausible candidates it saw. One unique match is high confidence; two is 0.75; more is 0.40. Anything below the threshold (default 0.80) goes to the review queue.
4. Hidden tests
calculate_percent_increase(group_df) takes a DataFrame, so it cannot be tested
with literal arguments. Instead the probe reads the column names and date dtype of
the student's own tidy frame, builds a synthetic group frame from interpolated
anchor points using those names, and calls the function with it. The expected
answer follows from the anchors, so it is known regardless of what the student
loaded. If the notebook crashed before producing a tidy frame, the probe falls
back to a frame carrying every common spelling (date/Date, ZHVI/value) at
once, so the function can still be tested.
The anchors deliberately imply a different answer for the first and last rows than for the two dates named in the assignment. That is how the most common wrong implementation is identified by name rather than just marked wrong:
| What the function does | Result |
|---|---|
| uses 2020-03-31 and 2022-03-31 | pass |
| uses the first and last rows | 50% credit, named in the feedback |
returns 0.5 instead of 50 |
60% credit, named as a units slip |
| left as the unfilled template | 0, detected by parsing the body, not by searching for the template comment |
Hidden test calls run inside the student's kernel; the credit ratios and the
expected values stay on the grading host. Nothing in the container, and nothing in
the executed notebook saved back to results/, reveals the answer key — the probe
cells are stripped before the notebook is stored. A test asserts this.
| Signal | Result |
|---|---|
| deterministic test, or a matched expected value | confidence 1.0 |
| one uniquely matching object | 0.98 |
| two plausible objects | 0.75 → review |
| high ambiguity | 0.40 → review |
| execution failure, multiple notebooks, missing objects | review |
| qualitative item without LLM grading | review |
| any deduction at all | review |
The last row is the strongest rule: anything short of full marks goes to the review queue. An automated deduction is a claim about a student's work, so a person signs off before it becomes a grade. Full marks need no defence and pass straight through. The queue entry names which items lost points and how many, and still sorts broken submissions above small deductions. Marking a submission reviewed — or overriding it back to full marks — clears it. The rule can be turned off in Settings for a large class.
Both scores are always kept:
{ "automatic_score": 12, "manual_override": true,
"final_score": 15, "override_reason": "Alternative valid implementation." }Re-running the grader regenerates the automatic scores; Preserve manual overrides restores the human decisions on top.
Everything the grader expects lives in rubrics/hw1.yaml and rubrics/hw2.yaml —
the Center City ZIP
list, the comparison dates, the hidden test anchors, the credit ratios for common
near-misses. Change the YAML, press Regrade; no Python edits required.
Before grading a real class, check the two things the assignment fixes: the
center_city_zipslist matches the one printed in the student template, andstart_date/end_datematch the dates in the instructions. Nothing else needs updating when the data changes — that is the point of the design above.
For Assignment 2 there is nothing to correct against an official solution — no
expected values exist. What rubrics/hw2.yaml holds instead is policy: how much a
chart that never rendered is worth, what counts as a transformation, how many
words make a discussion, and the ceiling on an unreviewed aesthetics score.
rubrics/hw3.yaml is the opposite extreme, and it is the one file to check when
the assignment's data changes. Every expected count and median in it was computed
from assignment_template/assignment3-data.zip; hand out a different zip and
they all move together. Re-run the reference solution and update the numbers, or
widen the tolerances — no Python changes either way. Those numbers are also what
makes grading from saved outputs possible at all: 384 in a notebook means
something only because the rubric knows Philadelphia has 384 tracts in this file.
examples/data/ holds a synthetic ZHVI file and a synthetic 311 extract, used
only to exercise the pipeline. Neither is an answer key: no check compares against
them.
app.py Streamlit entry point
cli.py headless grading
ui/ one module per page, plus shared components and state
grader/
service.py orchestration; the only entry point the UI needs
discovery.py folders, Canvas ZIPs, notebook selection
executor.py instrumentation, Docker/local execution
probe_runtime.py injected into the student kernel; never runs on the host
notebook_runner.py standalone runner executed in the subprocess/container
notebook.py static analysis and read-only rendering
charts.py chart evidence: library attribution, Vega-Lite specs
answers.py the answers a notebook's own saved outputs state
inspection.py candidate matching over the probe payload
scoring.py review policy, class summary
feedback.py student markdown feedback
results.py session persistence and exports
llm.py optional qualitative grading
assignments/
base.py rubric dispatch, shared execution check
hw1.py Assignment 1 checks
hw2.py Assignment 2 checks
hw3.py Assignment 3 checks
rubrics/hw1.yaml the grading policy, as configuration
rubrics/hw2.yaml the grading policy, as configuration
rubrics/hw3.yaml the answer key, as configuration
docker/ sandbox image, app image, pinned sandbox packages
docker-compose.yml runs the app image
.github/ CI workflow and Dependabot
tests/ 302 tests
examples/ synthetic data, nine demo notebooks, fifteen submissions
assignment_template/ the notebooks handed to students, and HW3's data zip
results/<session_id>/
├── grades.csv student_id, score, execution status, review flags
├── grading_session.json full state; reload it from the Overview page
├── summary.json class summary
├── flagged_submissions.csv the review queue
├── class_report.md
├── feedback/<student>.md student-facing markdown
├── executed_notebooks/ probe cells stripped
└── raw_results/<student>.json rubric-level detail with evidence
python -m pytest # 302 tests, ~40s
python -m pytest -m "not slow" # skip the ones that execute real kernels
python -m pytest -m docker # grade a notebook in the sandbox image (needs it built)
ruff check . # syntax errors and undefined namesCI (.github/workflows/ci.yml) runs the suite on Python 3.10–3.13 for every push
and pull request, then builds both images, grades a notebook inside the sandbox,
grades the demo class from inside the app container, and checks the UI answers.
Dependabot opens weekly update PRs for the grader's packages, the sandbox's
pinned packages, the base images and the Actions; each goes through the same CI.
Implemented: Assignments 1, 2 and 3, Streamlit UI, Docker isolation, rubric engine, review queue, manual overrides, regrading, exports, optional LLM grading.
Not built (per design.md §37): Canvas API integration, authentication, cloud
deployment, student accounts, a database, live scraping, Assignments 4–6. Their
templates are in assignment_template/ and the architecture is ready for them —
each needs a rubric YAML and a grader class.
Adding an assignment means a rubric YAML plus a grader class in assignments/
that registers check_<rubric_id> methods — assignments/base.py handles
dispatch, the execution check and error containment.