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.
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, 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.
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, 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.
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.
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.
| 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 actually fails when reverted | run it twice | Deterministic and catches the test-that-cannot-fail class. |
| Is the approach reasonable | model judge | Genuinely requires reading. §3. |
| Would a reviewer be annoyed | model judge | Same, and lower reliability. Use sparingly. |
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, 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.
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.
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.
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.
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.
| 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 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.
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.
The loop only closes when the result lands where the decision is made, which is 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 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.
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.
Three practical facts, and one failure mode that will get you eventually.
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.
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 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.