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.
github.com/tacoda/fulcorum-eval-loopYour 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.
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.
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.
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.
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",
]
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.
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.
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.
| Property | Instrument | Why |
|---|---|---|
| Did it build, do tests pass | exit code | Already exists. Use it. |
| Money as integer cents | regex on the diff | Crude, catches the real cases, zero cost. |
| Touched only allowed paths | set comparison | Scope creep is the easiest defect to detect and the most common. |
| Test fails when reverted | run it twice | Deterministic and catches the test-that-cannot-fail class. |
| Is the approach reasonable | model judge | Requires reading. §3. |
| Would a reviewer be annoyed | model judge | Same, and lower reliability. Use with care. |
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.
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.
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.
"""
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.
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.
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.
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.
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.
| Change you want to detect | Samples per arm | With 20 tasks, that is |
|---|---|---|
| 70% → 75% (small win) | ~1,250 | 60+ runs per task. Not happening. |
| 70% → 80% | ~300 | 15 runs per task. Expensive but possible. |
| 70% → 85% | ~120 | 6 runs per task. Feasible. |
| 70% → 50% (a regression) | ~95 | 5 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 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.
The loop closes only when the result lands where the reviewer decides: the pull request, not a dashboard nobody opens.
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
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.
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.
Three practical facts, and one failure mode that will get you.
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.
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.
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.