Harness Engineering / The Eval Loop / worked example

You changed the charter. Did it get better?

Guardrails decide what the agent may not do. A factory decides what it produces. Neither one tells you whether last Tuesday's rule change helped, hurt, or did nothing. So without an instrument, every charter edit is an act of faith performed by the person with the strongest opinion. This is the smallest thing that answers the question: a frozen set, assertions before judges, blind pairwise comparison, and an honest account of what sixty runs can detect.

who it's for · anyone maintaining a charter, a rules file, or a harness they keep editing
runnable skeleton · github.com/tacoda/fulcorum-eval-loop
what you need · twenty tasks, pytest, and deliberately no eval framework
pairs with · Five Guardrails (what to enforce) · The Factory (what runs it)
the honest limit · §5. Read it before you report a number to anyone
Deterministic
Exit codes, greps, file checks. Free, instant, and incapable of drifting. Most of what you want lives here.
Judged
A model reading output. Necessary for some properties, noisy for all of them, and it needs validating like any other instrument.
Below the noise
Differences your sample size cannot see. The discipline is naming these differences rather than reporting them.
0 The gap

The one file nobody tests

Your code has tests. Your infrastructure has a plan. The document that shapes every line the agent writes gets edited on a hunch and merged on a nod.

A charter accumulates. Somebody adds a paragraph after an incident. Somebody else adds three rules about naming, and a fourth person pastes in a style guide. Each addition is defensible on its own, and nobody measures the total. Two failure modes follow, and both are silent. Rules contradict each other, so the agent picks one at random per session. Or the document gets long enough that the agent stops following the instructions at the bottom. Nobody notices which third stopped working.

The reason is not laziness: measuring a non-deterministic system looks like it needs apparatus nobody has time to build. It does not. The version below is roughly a hundred and fifty lines of Python, and catches both failure modes within a week.

what gets tested, and what does notthe asymmetry
src/billing.py        unit tests, types, review, CI
infra/main.tf         plan, policy check, review
.github/workflows/    runs on every PR, fails loudly

CLAUDE.md             merged on a nod
.claude/rules/*.md    merged on a nod
prompts/*.txt         merged on a nod

The bottom three decide the shape of everything in the top three.
The claim this page defends

A charter change should arrive with evidence, in the same way a code change arrives with a test. Not a benchmark, not a leaderboard. Evidence: on twenty frozen tasks, this edit made things better, worse, or nothing we can distinguish.

1 Freeze

Twenty tasks, checked in, never edited casually

There is no evaluation without fixed inputs. Everything else on this page is downstream of having a set you refuse to change on a whim.

size 20 tasks rule changing the set resets history
evals/tasks/007_refund_rounding.pyone task, one file
FIXTURE = "fixtures/billing"          # copied to a temp dir per run

PROMPT = """
Refunds for cancelled orders are being rejected when the order
included a percentage discount. Fix it so a cancelled discounted
order can be refunded in full.
"""

# What "done well" means here, in this repo, for this team.
CHECKS = [
    "compiles", "tests_added", "money_is_integer",
    "no_unrelated_files", "policy_cited",
]

Choosing twenty

  • Real tickets, past tense. Take work already done: you get a known-good outcome for free, and a task shaped like your real work. No synthetic task has that shape.
  • Spread across the failure modes you care about. If half your charter is about money handling, several tasks should touch money. The set states what you worry about.
  • Include three you expect to fail. A set everything passes cannot show improvement, only regression, so headroom is a design requirement.
  • Two that need a refusal. Tasks where the correct behaviour is to stop and ask. Without them, charters that are pure encouragement score too well.
The rule that makes the numbers mean anything

Editing the task set invalidates every prior result. Treat it like changing the units on a chart: allowed, sometimes necessary, and it resets the history. Version the set (v1, v2), and record which version produced each number. Without that record, within two months you will compare measurements taken with different rulers and conclude things about your charter.

2 Assert

Most of what you want is a grep or an exit code

The first instinct is to reach for a model judge. Resist it for one afternoon and you will find that four fifths of your rules are mechanically checkable.

evals/checks.pyfree, instant, cannot drift
def compiles(w, r):
    return run(w, "make build").returncode == 0

def tests_added(w, r):
    return any(p.name.startswith("test_") for p in r.files_changed)

def money_is_integer(w, r):
    # the charter says money is integer cents, everywhere
    return not re.search(r"float\(|/ 100\b", r.diff)

def no_unrelated_files(w, r):
    # scope creep, class 5 in the review taxonomy
    return set(r.files_changed) <= set(TASK.allowed_paths)

def policy_cited(w, r):
    return "CHARTER" in r.summary or "policy" in r.summary.lower()

Each check above turns one charter rule into a measurement. Compiling the same rule into a hook is the other half of the move, aimed at enforcement instead of measurement. The payoff is the same either way: a property a script can check stops being a matter of opinion.

which properties need which instrument
PropertyInstrumentWhy
Did it build, do tests passexit codeAlready exists. Use it.
Money as integer centsregex on the diffCrude, catches the real cases, zero cost.
Touched only allowed pathsset comparisonScope creep is the easiest defect to detect and the most common.
Test fails when revertedrun it twiceDeterministic and catches the test-that-cannot-fail class.
Is the approach reasonablemodel judgeRequires reading. §3.
Would a reviewer be annoyedmodel judgeSame, and lower reliability. Use with care.
The "test fails when reverted" check is the best one here

Revert the implementation, keep the new test, run the suite. If it stays green the agent wrote a decoration. Two lines of shell, catches a defect class that survives every review, and no judge required.

3 Judge

A judge is an instrument, so calibrate it

Nobody would trust a thermometer they had never compared against a known temperature. Model judges get deployed on the strength of the prompt sounding sensible.

pin the model version one property per call validate against 20 human labels
evals/judge.pybinary, anchored, pinned
JUDGE_MODEL = "claude-sonnet-5"   # pinned. changing it resets history.

RUBRIC = """
Answer YES or NO, then one sentence.

Question: does this change handle the cancelled-order case by
consulting the order state, rather than by catching an exception?

YES example: reads order.status before computing the refund.
NO example:  wraps the refund call in try/except and returns 0.

Answer NO if you are unsure.
"""

Four rules that do the work

  1. Binary Yes or no, never one-to-five. Numeric scales from a model are not interval scales. Averaging them produces a number with no meaning, and everyone puts that number in a slide anyway.
  2. Anchored A concrete example of each verdict, from your own codebase. Anchors move judges more than any amount of instruction prose.
  3. One property One question per call. Ask a judge to assess six things at once and it produces a summary judgment about vibes. Then it rationalises that judgment into six sub-scores.
  4. Pinned Record the exact judge model with every result. When the judge changes, historical comparisons are void, exactly as if you had swapped out the task set.
Validate it before you believe it

Label twenty outputs by hand, then run the judge on the same twenty. If it agrees with you on fewer than about sixteen, the judge is measuring something other than what you asked. The usual cause is a rubric that is clear only to someone who already knows the codebase. Fix the anchors and try again. This costs an hour, once, and is the difference between an instrument and a random number generator with good manners.

4 Pairwise

Compare two outputs, never score one

Absolute scores drift with everything: the weather in the prompt, the model version, the judge's mood. Relative comparisons on the same task are stable, and relative is all you ever needed.

Never ask "is this output good". Ask "is the charter on this branch better than the one on main". So run both charters on the same task, and ask the judge which it prefers. Hide the two labels, and swap the order.

evals/pairwise.pyblind, and both orders
def compare(task, charter_a, charter_b, k=3):
    wins_a = wins_b = ties = 0
    for _ in range(k):
        a = run_agent(task, charter_a)
        b = run_agent(task, charter_b)

        # ask twice, swapping which one is shown first
        first  = judge_prefers(task, a, b)   # -> "first" | "second"
        second = judge_prefers(task, b, a)

        # position bias: if the judge picks the same SLOT both
        # times, it is not reading. Discard the sample.
        if first == second:
            ties += 1
        elif first == "first":
            wins_a += 1
        else:
            wins_b += 1
    return wins_a, wins_b, ties

The discard is the important line. Position bias is well-documented and ordinary: a judge can prefer whichever output it sees first. Run one order and that bias passes into your results undetected. Run both orders and the bias becomes a visible tie, which is honest and costs one extra call.

Watch the tie rate

Ties above roughly a third mean the judge is not distinguishing the two charters at all. That is a real finding, and usually the correct one: most charter edits change nothing measurable. A loop that reports the tie is worth more than one that always finds a winner.

5 The arithmetic

What sixty runs can and cannot detect

Write-ups leave this section out, and that omission costs a team a quarter spent tuning a charter against noise.

Twenty tasks run three times is sixty samples per arm, which sounds like plenty and is not. Take a pass rate somewhere around seventy percent. Then the rough sample sizes needed to detect a change with any confidence look like this.

approximate samples per arm, two-proportion comparison, 80% power at the 5% level
Change you want to detectSamples per armWith 20 tasks, that is
70% → 75% (small win)~1,25060+ runs per task. Not happening.
70% → 80%~30015 runs per task. Expensive but possible.
70% → 85%~1206 runs per task. Feasible.
70% → 50% (a regression)~955 runs per task. Comfortably detectable.

Read the table the right way round. A twenty-task set with a few runs each is a regression detector. It will tell you when a charter edit broke something, which is the failure that costs you money. It will not tell you which of two reasonable phrasings is five percent better. A loop that appears to answer that question is reporting noise with a confident face.

Report it the way it deserves

Report three outcomes: regression, improvement, and indistinguishable. Regression means worse on multiple tasks, beyond the tie rate. Improvement means better on multiple tasks, and large. The third is a legitimate result rather than a failure to get one, and most edits land there. A team that can say "indistinguishable" out loud is a team whose other two verdicts mean something.

Two cheap changes raise sensitivity without buying runs. Use paired comparison, which the design in §4 already does: pairing is more sensitive than comparing two independent pass rates. Then make the deterministic checks in §2 carry as much of the load as they can. Those checks add no judge noise on top of the agent's own variance.

6 The loop

A charter change is a pull request with evidence

The loop closes only when the result lands where the reviewer decides: the pull request, not a dashboard nobody opens.

.github/workflows/charter-eval.ymlruns only when the charter moves
on:
  pull_request:
    paths:
      - "CLAUDE.md"
      - ".claude/rules/**"
      - "prompts/**"

jobs:
  eval:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - run: python -m evals.pairwise --base origin/main --head HEAD --k 3
      - run: gh pr comment "$PR" --body-file evals/out/report.md
what lands on the pull requestthe whole output
Charter eval · task set v3 · judge claude-sonnet-5 · k=3

  deterministic checks     main 84/100   pr 91/100
    money_is_integer       16/20 → 20/20  (the rule you added)
    no_unrelated_files     19/20 → 15/20  ← regression

  pairwise (judged)        pr wins 6 · main wins 4 · ties 10

  VERDICT  REGRESSION on scope creep. The new section appears to
           license refactoring while nearby. Judged difference is
           indistinguishable (tie rate 50%).

That report is the point of the whole exercise. It names what got better, names what got worse, and stays agnostic about the part it cannot resolve. A reviewer can act on all three statements.

The norm this creates

A charter pull request with no eval result becomes as odd as a code pull request with no tests. That norm is worth more than any number the loop produces, because it stops the document growing by accumulation.

7 Upkeep

Cost, cadence, and the way this rots

Three practical facts, and one failure mode that will get you.

Cost

A full pairwise run is tasks × k × 2 agent invocations plus tasks × k × 2 judge calls. Twenty tasks at k=3 is 120 agent runs and 120 judge calls. Price that against your own provider once, put the figure in the README, and revisit it when the number changes. That bill is usually small next to one senior engineer spending a day arguing about a paragraph.

Cadence

  • On charter pull requests, always. This is the whole point.
  • Weekly on main, unchanged charter. This catches drift from underneath you: a model update, a dependency change, a fixture that rotted.
  • Never continuously. A loop that runs hourly generates numbers faster than anyone can read them. Then people start ignoring it by the second week.
The failure mode: overfitting to twenty tasks

Tune long enough and the charter gets very good at these twenty tasks and no better at the job. The symptom is a rising score with no corresponding change in what people complain about. The countermeasure is a held-out set: five tasks you run once a quarter and never tune against. If the twenty improve and the five do not, you have been optimising the instrument.

Where to start

Five tasks, the deterministic checks from §2, and no judge at all. That takes an afternoon, costs nothing to keep, and catches the next charter edit that licenses scope creep. Everything above §2 is what you add once the cheap version has proved it earns its keep.