Benchmark-driven development¶
A semantic function is a function whose implementation is a model
call: triage(text) -> Category, summarize(thread) -> str. Its
signature is code, but its behavior is a distribution — the honest
contract is not “returns billing for refund emails” but “returns
billing for refund emails at least 95% of the time, under $0.001 a
call.”
Test-driven development gives you a loop — red, green, refactor — built on an instrument that reads true or false. Pointed at a distribution, that instrument lies in both directions: a wrong prompt can pass a single-shot test on a lucky sample, and a good prompt can fail one on an unlucky sample. Benchmark-driven development keeps the loop and swaps the instrument: the unit of feedback is the pass fraction.
Red is a low fraction, not a red cross:
6/10 FLAKY.Green is a fraction meeting the bar you chose:
10/10, or ≥ 95% by policy.Refactor means changing the prompt or model — compared on an axis against the incumbent, same cases, same sample size, cost attached.
The benchmark stays forever as the regression net, exactly like the test you keep after fixing a bug.
The three outcome classes keep the feedback clean while you iterate:
FAIL/FLAKY rows are prompt problems, ERRORED rows are harness
problems — different fixes, never blurred into one signal.
When a prompt fails in production¶
The loop most teams meet first is not greenfield — it’s an incident. A support bot misroutes a real message: “my card was charged twice” lands in the wrong queue. Someone tries the message once in a playground, gets the right answer, and the ticket drifts toward cannot reproduce.
That is the single-shot trap. A semantic bug is usually not “the prompt always fails on this input” — it’s “the prompt is a coin flip on this shape of input, and production finally sampled the bad side.”
Important
The reproduction of a semantic bug is a fraction, not a red test.
A semantic bug won’t reproduce in one run — and can’t hide from ten.
The playground retry that “works now” is a sample size of one; the
6/10 FLAKY row is the reproduction.
The loop, applied to the incident¶
1. Reproduce: turn the incident into a case¶
Take the exact production input and pin its expected label as a case — alongside the cases you already trust — then sample it:
# bench_triage.py
import pytest
from pytest_probability import record_usage
PROMPT_V1 = "Categorize: {text}"
@pytest.mark.parametrize("text,expected", [
# the production incident, verbatim
pytest.param("my card was charged twice", "billing", id="refund"),
# known-good coverage
pytest.param("i cannot reset my password", "account", id="password"),
pytest.param("the box arrived two weeks late", "shipping", id="late_box"),
])
def bench_triage(text, expected):
response = llm.complete(PROMPT_V1.format(text=text))
record_usage(model=response.model, input_tokens=response.input_tokens,
output_tokens=response.output_tokens, cost=response.cost)
assert response.category == expected
$ pytest bench_triage.py --prob-runs=10
...
triage::refund-v1 6/10 $0.0002 FLAKY
triage::password-v1 8/10 $0.0002 FLAKY
triage::late_box-v1 10/10 $0.0002
Overall: 24/30 passed (80%)
Reproduced — and better than reproduced: the incident case fails 4
times in 10, and the sampling caught a second liability
(password, 8/10) nobody had filed a ticket for yet. This table is
the red state.
2. Fix: put the candidate prompt on an axis¶
Don’t edit the prompt in place — add the candidate next to the incumbent, so the comparison runs on the same cases, the same sample size, with cost attached:
PROMPTS = {
"v1": "Categorize: {text}",
"v2": ("You are a support triage bot. Categorize the message into "
"exactly one of: billing, account, shipping. Reply with the "
"category word only.\n\nMessage: {text}"),
}
@pytest.mark.parametrize("prompt", ["v1", "v2"])
@pytest.mark.parametrize("text,expected", [...]) # same cases
def bench_triage(text, expected, prompt):
response = llm.complete(PROMPTS[prompt].format(text=text))
...
triage::refund-v1 6/10 $0.0002 FLAKY
triage::refund-v2 10/10 $0.0006
triage::password-v1 8/10 $0.0002 FLAKY
triage::password-v2 9/10 $0.0006 FLAKY
triage::late_box-v1 10/10 $0.0002
triage::late_box-v2 10/10 $0.0006
Now the decision is a table, not a vibe: v2 fixes the incident case
outright, lifts password to 9/10, holds late_box — at three times
the token cost. Whether 9/10 clears the bar is a product call; the
point is you are making it with numbers.
3. Guard: the benchmark outlives the fix¶
Promote v2, delete the v1 axis value, and keep the benchmark in CI — the incident case is now a permanent regression sentinel, and every future prompt tweak reruns it ten times. For a CI gate softer than perfection, check the JSON report:
pytest benchmarks/ --prob-runs=10 --prob-json=report.json || true
jq -e '.rows[] | select(.case == "triage::refund") | .pass_rate >= 95' \
report.json > /dev/null || { echo "incident case regressed"; exit 1; }
The habit¶
Greenfield benchmark-driven development is the same loop starting one
step earlier: write the cases before the prompt — they are the
behavior spec — take your red 0/N run, and iterate prompts up the
fractions with the cost column keeping score. Every production
incident thereafter follows the path above: verbatim input → case →
fraction → fix on an axis → sentinel forever.