Skip to content
Harness Engineering2026-09-119 min readRev. 2026-09-30

Turn a Notebook Cell Into a Contract Your Eval Harness Can Call

A notebook cell that parses one judge verdict is not yet a function a harness can call. Declare accepted inputs, rejected inputs and a stable output, then run eight cases through every entry point.

Key takeaways

  • A notebook cell that worked on inputs you typed has not declared which inputs it accepts, which it rejects, or what it returns.
  • A callable contract names three things: accepted inputs, rejected inputs with a stable reason, and one output shape on every path.
  • An adapter that decodes JSON loosely can undo a strict validator upstream, so run the same expected-results table through every entry point.
  • Passing the contract proves shape and type. It does not prove the judge's score is right; that is a separate measurement.
  • A reviewer reading a work sample can check all of this in a minute: one function, one table, and a note of what it does not prove.

Rod Rivera

Author

Turn a Notebook Cell Into a Contract Your Eval Harness Can Call

Rod's note: read with a pencil; the margins are for you.

The cell that works is not the function that ships

You have a notebook cell that takes one judge verdict, checks it and prints a tidy row for your results table. You ran it on three verdicts you typed yourself, and it worked every time. A teammate wants to call it from the eval harness, where verdicts arrive as JSON from a queue, thousands per run. They ask one question: what happens when the judge sends the score as "3" instead of 3?

You do not know, because you never sent that value. The cell was tested against inputs you chose, not the inputs a model and a transport layer will actually produce.

That gap, between "ran once on data I built" and "a function another program can depend on", is the subject here. A contract is not a nicer word for a function. It is a declared boundary: which inputs are accepted, which are rejected and why, and what the caller receives either way. Until those are written down and checked, a notebook result is a demonstration.

What a contract has to name

Accepted inputs. Field names, types and ranges the function processes without complaint. A rubric score typed as an integer from 0 to 4 is a commitment. If the sender provides 5, the function refuses before doing any work; it does not clamp the value to 4.

Rejected inputs, with a stable reason. A caller needs to know not only that -1 fails but why, and to tell that apart from a missing field. Missing and null are different facts. Collapsing both into one "bad input" forces every caller to guess.

One output shape. On success, the same field names and types every time, whichever path produced them. If the local function returns an integer score and the queue consumer returns the string "3", you have two contracts wearing one name.

A worked contract: validate_verdict

The notebook version, made strict. It runs on Python 3.12 (Colab's version) with Pydantic 2.

python
import json
from typing import Union

from pydantic import BaseModel, ConfigDict, Field, ValidationError


class Contract(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")


class Verdict(Contract):
    case_id: str = Field(min_length=1, max_length=40)
    score: int = Field(ge=0, le=4)


class RejectedVerdict(BaseModel):
    error_code: str
    field: str
    detail: str


def validate_verdict(payload: dict) -> Union[Verdict, RejectedVerdict]:
    try:
        return Verdict.model_validate(payload)
    except ValidationError as exc:
        first = exc.errors(include_input=False, include_context=False)[0]
        return RejectedVerdict(
            error_code=first["type"],
            field=".".join(str(part) for part in first["loc"]),
            detail="case_id or score failed validation",
        )

Call it with {"case_id": "q-0417", "score": 3} and you get a Verdict with an integer score. Call it with "score": "3" and you get a RejectedVerdict naming score and the code int_type, because strict mode does not convert a numeric string. That refusal is a decision worth stating: the judge is expected to produce the declared type, and a judge that does not is telling you something about its reliability. A companion article shows what lax parsing does to a pass rate.

The function returns one of exactly two typed shapes, never a bare dictionary. Programming errors can still raise. Pin the Pydantic version and test the error codes before treating them as a permanent interface.

Where the contract breaks on the way to the harness

The notebook never exercised the path your teammate will use: JSON arriving from somewhere else. Here is a toy in-process adapter that plays that role. It is not a claim about any deployed queue or server.

python
def request_adapter(raw_body: str) -> str:
    """Toy stand-in for a queue consumer or HTTP handler: decode JSON,
    call validate_verdict, encode the result. Runs in-process only."""
    try:
        payload = json.loads(raw_body)
    except json.JSONDecodeError:
        return RejectedVerdict(error_code="invalid_json", field="body",
                               detail="body must be valid JSON").model_dump_json()
    return validate_verdict(payload).model_dump_json()


print(request_adapter('{"case_id": "q-0417", "score": 3}'))
print(request_adapter('{"case_id": "q-0417", "score": "3"}'))
text
{"case_id":"q-0417","score":3}
{"error_code":"int_type","field":"score","detail":"case_id or score failed validation"}

The adapter and the direct call reach the same decision for the same body. That proves something narrow: within this process, both paths share one type decision. It proves nothing about a real message broker, a client library's encoder, or a framework that parses request bodies before your code sees them.

The failure this setup is built to catch is an adapter written separately from the validator, one that "helpfully" converts "3" to 3 while decoding. Then the validator never sees the string and cannot reject it. JSON does distinguish 3 from "3"; a hand-rolled converter can erase that difference without anyone noticing until the pass rate is off.

Where a score contract can be silently rewritten. Judge returns score; JSON body arrives; Adapter decodes body; validate_verdict checks type; Typed verdict or rejection
Where a score contract can be silently rewritten

The risk sits on the arrow from "Adapter decodes body" to "validate_verdict checks type". The fix is not more validation code. It is making one function own the type decision and having every entry point, notebook or harness, call it with the untouched payload.

An in-process pass does not certify the pipeline

Running the adapter a thousand times in one notebook and counting agreements shows the logic is consistent. It says nothing about a deployed consumer, a serialization library's defaults, or concurrent writers to your results store. Treat it as a design check.

Build the expected-results table first

Before wiring any real transport, write the table every entry point must satisfy. The expected outcomes come from the contract, not from running the code.

Input scoreInput typeExpected outcomeReason
3integeraccepted, score 3In range, correct type
"3"stringrejected, int_typeStrict mode does not convert
TrueBooleanrejected, int_typebool subclasses int; strict still rejects
3.0floatrejected, int_typeNot the declared type
-1integerrejected, greater_than_equalBelow 0
5integerrejected, less_than_equalAbove 4
missingabsentrejected, missingRequired field not supplied
None / JSON nullnullrejected, int_typeField does not accept null

Then run it through both paths. The codes are Pydantic v2 codes, confirmed on 2026-09-30 with Python 3.12.13 and Pydantic 2.13.5.

python
cases = [
    ({"case_id": "q-0417", "score": 3}, None),
    ({"case_id": "q-0417", "score": "3"}, "int_type"),
    ({"case_id": "q-0417", "score": True}, "int_type"),
    ({"case_id": "q-0417", "score": 3.0}, "int_type"),
    ({"case_id": "q-0417", "score": -1}, "greater_than_equal"),
    ({"case_id": "q-0417", "score": 5}, "less_than_equal"),
    ({"case_id": "q-0417"}, "missing"),
    ({"case_id": "q-0417", "score": None}, "int_type"),
]
for payload, expected_error in cases:
    direct = validate_verdict(payload).model_dump()
    adapted = json.loads(request_adapter(json.dumps(payload)))
    assert direct == adapted
    assert direct.get("error_code") == expected_error
assert json.loads(request_adapter("{"))["error_code"] == "invalid_json"
print("Eight typed cases agree; malformed JSON has a declared rejection")

Each row checks a declared outcome and agreement between the two paths. A disagreement on any row is a regression, however harmless it looks. A local function returning 3 while the harness path returns "3" is the kind of drift someone finds weeks later, when "3" * 2 is "33" and a mean score comes out as nonsense.

Read the accepted shape off the model

Write down the field names, types and bounds your function actually enforces, from the validation model, not from memory.

Write the rejection table before adding paths

List every input you expect to fail and its reason. Boolean, float, string, out-of-range, missing and null each get their own row.

Run the table through every entry point

Call the notebook function and every adapter with identical payloads. A disagreement is the defect to fix, not the table.

Document both output shapes

State what a caller receives on success and on rejection, including the error fields. That is what makes the function callable rather than merely correct.

Direct validation and the JSON adapter are compared with independently declared expected outcomes. Eight independently declared cases; Compare both to expected outcomes; Direct validation; JSON adapter; Malformed JSON; Adapter rejects invalid_json
Direct validation and the JSON adapter are compared with independently declared expected outcomes

Where responsibility ends

Passing this contract establishes something specific. The verdict has the declared shape and type, in the declared range. It does not establish that q-0417 is a case in the current eval split, that the judge's 3 is the score a person would give, or that the verdict belongs to this run rather than last week's. Those are separate checks against separate evidence: the split manifest, a human-labeled agreement sample, a run identifier. Chapter 16 of the book measures judge agreement with Cohen's kappa and shows why raw agreement overstates it. Anthropic's notes on writing tools for agents make the matching design point: describe inputs, outputs and failures explicitly, because a caller relies on what is described.

Strictness is also narrower than it looks. Pydantic's strict-mode documentation notes that behavior differs by type and that JSON input has its own exceptions for types such as dates. The integer case here does not generalize to every field; check each type you add.

Quick check — A notebook cell parses one hand-typed judge verdict and prints the right score. What is still missing before the eval harness can call it?

Check the decision

What would prove this wrong

If you ran the table against the real harness entry point, not the toy adapter, and found that the queue path turned "3" into 3 while the notebook function still rejected it, that would falsify the claim that one shared function guarantees the same behavior everywhere. Something between the judge and your function, a framework's body parser, a serializer or a proxy, would be rewriting the payload. That failure is common, and finding it is the reason to run the table against the entry point your callers actually use. Nothing here claims that check has been run against a real pipeline; it is the next piece of work.

Why this matters in a lab

Research code moves from notebook to pipeline constantly: a scoring cell becomes part of a harness, a data-cleaning cell becomes a preprocessing job. The habit being tested is whether you can make that move without changing the result. In a take-home work sample, a reviewer can see it at once: one function with a typed input and output, a table of rejected inputs with reasons, one test that runs the table through every entry point, and a sentence stating what the contract does not prove. That reads as someone whose numbers survive being run by other people.

Read Chapter 2: typed tools that validate anyway

Chapter 2 builds typed tool interfaces with Pydantic, a dispatcher that validates every request, and tests that break requests before a model ever sends one.

Learn the mechanism. Test the claim.

Join the Prof Rod newsletter for one educational lesson a week, with worked examples attached. It is free to register for and separate from the Zero Employee community.