Skip to content

Writing Source Notebooks

Source notebooks are standard Marimo notebooks (.py files) with a few conventions for marking solutions and autograding checks. Create them with marimo edit and place them in source/<assignment>/<assignment>.py.

Solution markers

Wrap model solutions in ### BEGIN SOLUTION / ### END SOLUTION markers. When you run mograder generate, these blocks are replaced with # YOUR CODE HERE and pass in the release version:

@app.cell
def _(np):
    def finite_diff(x, y):
        ### BEGIN SOLUTION
        dydx = np.zeros_like(y)
        dydx[0] = (y[1] - y[0]) / (x[1] - x[0])
        dydx[-1] = (y[-1] - y[-2]) / (x[-1] - x[-2])
        dydx[1:-1] = (y[2:] - y[:-2]) / (x[2:] - x[:-2])
        ### END SOLUTION
        return dydx

    return (finite_diff,)

For written-response cells, assign the model answer to response_text inside a solution block. The generated release version is automatically converted to an editable mo.md() block for the student:

@app.cell
def _(mo):
    response_text = "*Write your analysis here...*"
    ### BEGIN SOLUTION
    response_text = r"""
    The finite difference method approximates derivatives using nearby
    function values. Central differences achieve second-order accuracy...
    """
    ### END SOLUTION
    mo.md(response_text)
    return (response_text,)

Autograding checks

Import check from mograder.runtime and call it with a label and a list of (condition, failure_message) tuples. Each tuple can optionally include a weight as a third element (default 1). The result is a coloured callout that gives students instant feedback:

from mograder.runtime import check

check(
    "Q1: Palindrome checker",
    [
        (is_palindrome("racecar") is True, 'is_palindrome("racecar") should be True'),
        (is_palindrome("hello") is False, 'is_palindrome("hello") should be False'),
    ],
)

Use mo.stop() with an empty-checks call to show an amber "waiting" state before the student has written any code:

@app.cell(hide_code=True)
def _(check, mo, x):
    mo.stop(x is None, check("Q1: Array creation", []))
    check("Q1: Array creation", [
        (x.shape == (50,), f"x should have shape (50,), got {x.shape}"),
    ])
    return

Holistic vs per-question marks

Holistic mode (single mark 0-100, assigned by a marker): import the standalone check function. This is suited to notebooks where coding questions provide formative feedback only and a marker assigns one overall mark:

from mograder.runtime import check

Per-question marks (automatic + manual): use the Grader class with a marks dictionary. Questions matching a check() label are auto-scored with partial credit: marks are proportional to the weight of passing checks. Questions without a matching check (e.g. written analysis) are scored manually by the marker:

from mograder.runtime import Grader

# === MOGRADER: MARKS ===
_marks = {"Q1": 10, "Q2": 15, "Analysis": 60}
grader = Grader(_marks)
check = grader.check

Each check tuple can optionally include a weight as a third element (default weight is 1). Earned marks are round(available * earned_weight / total_weight, 1):

check("Q2: Finite differences", [
    (isinstance(dydx, np.ndarray), "result should be ndarray"),       # weight 1
    (dydx.shape == x.shape, "shape should match"),                    # weight 1
    (np.max(np.abs(dydx - np.cos(x))) < 0.05, "max error < 0.05", 3),  # weight 3
])
# If only the first two pass: earned = round(15 * 2/5, 1) = 6.0/15

The question key is the text before the first colon in the check label, so check("Q1: Array creation", [...]) maps to the "Q1" entry. Call grader.scores() in a cell to display a reactive score table showing earned/available marks (including fractional values for partial credit).

Hidden tests

You can include checks that are visible to instructors but hidden from students. Wrap them in ### BEGIN HIDDEN TESTS / ### END HIDDEN TESTS markers. During mograder generate, these blocks are replaced with a # HIDDEN TESTS placeholder comment. During mograder autograde, the hidden tests are reinjected from the source notebook and executed:

@app.cell(hide_code=True)
def _(check, mo, np, x, y):
    mo.stop(x is None, check("Q1: Array creation", []))
    # Visible checks — students see these
    check("Q1: Array creation", [
        (isinstance(x, np.ndarray), "x should be a numpy array"),
        (x.shape == (50,), f"Expected shape (50,), got {x.shape}"),
    ])
    ### BEGIN HIDDEN TESTS
    check("Q1: Edge cases", [
        (abs(x[0]) < 1e-10, "x should start at 0"),
        (abs(x[-1] - 2 * np.pi) < 1e-10, "x should end at 2*pi"),
    ])
    ### END HIDDEN TESTS
    return

Hidden checks contribute to the mark total like visible checks. In the release notebook, grader.scores() shows a placeholder message instead of the score table when hidden tests exist, so students don't see misleading partial marks.

PEP 723 script dependencies

Include a PEP 723 metadata block at the top of the notebook so that marimo edit --sandbox and mograder validate can automatically install dependencies:

# /// script
# requires-python = ">=3.11"
# dependencies = [
#     "marimo",
#     "numpy",
#     "mograder",
# ]
# ///

mograder generate automatically adds mograder-assignment and mograder-cell-hashes lines to this block in the release notebook, enabling integrity checking during mograder validate.