Harness Engineering / The Eval Loop / worked example

You changed the charter. Did it get better?

Guardrails decide what the agent is refused. A factory decides what it produces. Neither one tells you whether last Tuesday's rule change helped, hurt, or did nothing, and in the absence of 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 actually 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 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, a fourth person pastes in a style guide. Each addition is individually defensible and nobody ever 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 instructions at the bottom stop being followed, and nobody notices which third stopped working.

The reason this persists is not laziness. It is that measuring a non-deterministic system feels like it requires apparatus nobody has time to build. It does not. The version below is roughly a hundred and fifty lines of Python and it will catch the two failure modes above 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 the task is genuinely shaped like your work, which a synthetic task never is.
  • Spread across the failure modes you care about. If half your charter is about money handling, several tasks should touch money. The set is a statement about what you are worried about.
  • Include three you expect to fail. A set everything passes cannot show improvement, only regression. Headroom is a design requirement.
  • Two that need a refusal. Tasks where the correct behaviour is to stop and ask. Charters that are pure encouragement score suspiciously well until you add these.
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, occasionally necessary, and it resets the history. Version the set (v1, v2) and record which version produced each number, or within two months you will be comparing measurements taken with different rulers and concluding things about your charter.

2 Assert

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

The instinct is to reach for a model judge immediately. 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 of these is a rule from the charter, compiled into a sensor. That is the same move as turning a rule into a hook, pointed at measurement instead of enforcement, and it has the same payoff: a property that is checked mechanically 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 actually fails when revertedrun it twiceDeterministic and catches the test-that-cannot-fail class.
Is the approach reasonablemodel judgeGenuinely requires reading. §3.
Would a reviewer be annoyedmodel judgeSame, and lower reliability. Use sparingly.
The "test actually fails" 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, and averaging them produces a number with no meaning that everyone will nonetheless put in a slide.
  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. A judge asked to assess six things at once produces a summary judgment about vibes and then rationalises it 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, and the usual cause is a rubric that is clear to a person 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.

The question is never "is this output good". It is "is the charter on this branch better than the one on main". So run both, on the same task, and ask the judge which it prefers, with the two labels hidden and the order swapped.

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. A judge that prefers whichever output it was shown first is a well-documented and entirely ordinary failure, and running only one order lets it pass silently into your results. Running both orders converts the bias from an invisible skew into 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, and a loop that reports that plainly is worth more than one that always finds a winner.

5 The arithmetic

What sixty runs can and cannot detect

This is the section that gets left out, and leaving it out is how a team spends a quarter tuning a charter against noise.

Twenty tasks run three times is sixty samples per arm, which sounds like plenty and is not. For a pass rate somewhere around seventy percent, 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 loudly when a charter edit broke something, which is the failure that actually costs you money. It will not tell you which of two reasonable phrasings is five percent better, and any loop that appears to answer that question is reporting noise with a confident face.

Report it the way it deserves

Three outcomes, and the third is a legitimate result rather than a failure to get one: regression (worse on multiple tasks, beyond the tie rate), improvement (better on multiple tasks, and large), and indistinguishable. Most edits land in the third bucket. A team that can say "indistinguishable" out loud is a team whose other two verdicts mean something.

Two cheap ways to buy sensitivity without buying runs: use paired comparison, which the design in §4 already does and which is substantially more sensitive than comparing two independent pass rates, and make the deterministic checks in §2 carry as much of the load as possible, because they have 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 only closes when the result lands where the decision is made, which is 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 is specific about the thing that got better, specific about the thing that got worse, and explicitly 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 individual number the loop produces, because it is what 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 eventually.

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. It 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 interpret them, and 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 is an afternoon, it has no ongoing cost, and it will catch the next charter edit that quietly licenses scope creep. Everything above §2 is what you add once the cheap version has proved it earns its keep.