Repository navigation
Releases: scottpmiller/evalcore
Release list
2.6.1: the facts 2.6.0 added reach a results store
The facts 2.6.0 added reach a results store.
Shipped as a patch. It changes what published rows contain, so the 1.0.0
policy would call it a minor; it goes out as 2.6.1 as a deliberate exception,
because it completes 2.6.0 rather than adding to it. Same call 2.4.2 made, for
the same reason.
Added
score_rowsexports the direction and range 2.6.0 introduced. That release
gave every metric both and then kept them in memory: the row shape this
module flattens a run into carried neither, so nothing reached a results
store and no consumer downstream could use them. Five columns on every row
shape, including the failed-invocation row that names no metric at all.value_range(none/bounded/unbounded_above/
unbounded_below/unbounded),value_minimumandvalue_maximum.
The discriminator is what makes "declared unbounded" and "never declared"
different answers, which two nullable floats cannot express; it is the
idiommetric_kind = 'none'already uses beside a filled-invalue. All
five members are spelled out becauseMetricRangecan express all five -
range: {max: 5}on a numeric field producesunbounded_below- and a
consumer storing this as an enum pays a migration to add a member later.metric_directionandhigher_is_better.metric_directioncarries the
same four valuesgate_windoes,noneamong them, meaning never
computed: an ungated run has no baseline, andneutralwould report
"measured, and it did not move" about a number nothing was measured
against.higher_is_betteris'true'|'false'|'unknown', andunknown
is narrower than it looks -compare()defaults a metric nothing
mentions to higher-is-better, so it readsunknownonly when a suite
declared the metric neutral or there was no comparison at all.
Changed
_passedis now_tristate, taking the spelling of its third state as an
argument.passedkeeps'null', which it has published since the row
shape existed;higher_is_betteruses'unknown', which reads as "nobody
declared one" rather than as a missing value. Private; no caller outside
this module.
2.6.0: a metric says what its numbers mean
A metric says which way is good and what its values sit on.
Added
MetricRangeand avalue_rangefield onScoreandMetricValue.
Nothing about a float says what it is on:0.86is 86% if the metric runs
0..1 and 4.3 out of 5 if it runs 1..5. A consumer holding only the number
guesses from magnitude, which is how a run whose costs happened to stay
under a dollar gets rendered as percentages, and how the same metric gets
classified differently depending on the window you look at.- Built-in graders declare their own ranges. Deterministic checks are 0..1;
classificationseparates its confusion-matrix metrics (0..1) from
support_*anderrors(0..unbounded); judge metrics are 0..1, being
points over the scale, exceptdisagreement, a spread in raw points,
which is 0..scale-1. range:on anumericfield spec, as{min, max}or a two-element list.
That grader surfaces whatever the adapter put in the field, so it is the
one place the engine cannot know. It is a separate key from themin/max
bounds beside it, which are a pass/fail threshold rather than a domain: a
cost that must stay under a dollar can still cost five.maximum=Noneis a positive statement - unbounded above, so not a
fraction of anything - and is different from carrying no range at all,
which says only that nobody declared one. Nothing is inferred from
observed values.directionandhigher_is_betteronMetricDelta. A delta's sign and its
meaning are different questions -f1rising is an improvement, and
false_negative_raterising is a regression - and until now the engine only
knew the difference for the single win metric, viawin_higher_is_better.
Every other metric came out ofcompare()as a bare number, so anything
downstream that wanted to rank, colour or summarize deltas had to keep its
own list of which metrics are inverted, or get it wrong.thresholds.metrics, an optional map declaring polarity per metric:
lower_is_better,higher_is_better, orneutralfor one that moves
without either direction being a result.- Polarity is inferred from the guardrails when it is not declared, which
is what makes this useful without editing a single existing suite. Amax
ormust_not_increaserule is only ever written about a metric you want
low, andmin/must_not_decreaseabout one you want high, so a gated
metric has already stated its direction. Precedence, strongest first: an
explicitmetrics:entry, thenwin_higher_is_betterfor the win metric,
then the guardrails. A metric none of them mention defaults to
higher-is-better; a metric fenced in on both sides by a band resolves to
neutralrather than falling through to that default, since a band says
neither direction is the good one.
Changed
_evaluate_winderives the win verdict through the same call that fills in
eachMetricDelta.direction, soComparison.winand the win metric's own
delta row cannot disagree about the same number.
2.5.0
What's Changed
A suite can declare the modules it needs imported.
Added
-
plugins:on a suite: a list of module paths the runner imports before it looks up anytype, so a suite that names a custom adapter or grader resolves it without a flag at the call site. Registration is an import side effect and nothing in the engine imports a consumer's module on its own, so until now every entry point had to remember--plugins my.graders(CLI) or a bareimport my.graders(Python API) - and the two could disagree. A suite is now self-contained: the same file runs from the CLI, from a consumer's ownrun_eval.py, and from a test with nothing to remember.--pluginsis unchanged and still the way to add a module without editing the suite, which would changesuite_hash.The import happens when a run starts, never in
load_suite. Parsing, hashing, diffing or reporting on a suite executes no consumer code, so a suite you have not decided to run is still only data.compareandreporttherefore do not import a suite's plugins - they do not need the registries.A module that cannot be imported raises
ConfigErrornaming it, rather than the unknown-typeerror one lookup later.
Fixed
examples/quickstart/run_eval.pycalledJsonlOutboxExporter.export(), removed when 2.2.0 named the exporter seam, sojust example-apihad been failing withAttributeErrorsince. It now exports score rows only, which is the one grain the store has: a scorecard is a read-time aggregation over those rows, so exporting it too would persist something derived that could disagree with them.
Changed
examples/quickstartdeclares its ownplugins:and no longer needs--pluginson the command line, nor theimport ... # noqa: F401that three of its entry points carried to force registration.graders.pyno longer importsadapter.pyfor the side effect either. The example is the same eval; it just stops demonstrating the workaround.
Upgrading: nothing breaks. Existing suites have no plugins: key and keep working exactly as before, with --plugins or an explicit import. Adding the key to a suite changes its suite_hash, which is correct - it changes what code runs - so expect one re-baseline on the suites you convert.
2.4.3
What's Changed
Live Anthropic judges work on current Claude models again.
Fixed
- The Anthropic judge and pairwise clients no longer send
temperature=0.temperature(withtop_p/top_k) was removed from the Claude request surface at Opus 4.7, and sending it at all is a 400 there and on every model after it - so a judge or a pairwise comparison pinned toclaude-opus-4-7,claude-opus-4-8,claude-opus-5,claude-sonnet-5orclaude-fable-5failed every call. The OpenAI clients still send it; that API still takes it.
Changed
- Live judge
max_tokensdefaults are now 8192 (from 1024 on the rubric judge, 512 on pairwise). Thinking is on by default from Opus 5 and Sonnet 5 onward andmax_tokensbounds thinking plus reply together, so a 1024-token budget could be spent on reasoning before the forced tool call landed - which surfaced as error scores rather than as an error. It is a ceiling, not a spend: a model that does not think generates the same handful of tokens it did before.
Upgrading: a judge on a model that still accepts temperature (Sonnet 4.6, Opus 4.6, the 4.5 line and older) now samples at the API default instead of 0, so its scores are no longer pinned run to run - expect more variance in a rubric dimension or a win-rate than before, and re-baseline if a gate sits close to its threshold. A judge on a thinking model also now bills thinking tokens on every call. Pass max_tokens= to a client to keep the old budget.
2.4.2
What's Changed
Every llm_as_judge row now describes what it measures.
Shipped as a patch. It changes what published rows contain, so the 1.0.0 policy would call it a minor; it goes out as 2.4.2 as a deliberate exception, alongside 2.4.1's judge-pin fix that it completes. No schema change — the columns already exist and were empty.
Fixed
-
A judge's dimension row (
<grader>.<dimension>) carried no judge information at all: nojudges.*, andjudge_scaleof 0. So0.400on a row could not say who scored it, on what scale, or that the raw number was 2 out of 5 — the scale lived only on<grader>.overall, and recovering the raw point meant joining back to it.Each dimension row now carries the verdict for the dimension it measures, with
judges.pointsscoped to that dimension:metric value scale judges.score judges.points quality.accuracy 0.8 5 [0.8] [{accuracy: 4}] quality.empathy 1.0 5 [1.0] [{empathy: 5}] quality.overall 0.9 5 [0.9] [{empathy: 5, accuracy: 4}]Carrying the full map on a dimension row would put irrelevant numbers on it and invite reading the wrong one.
<grader>.overallis unchanged.This establishes one invariant across every judge row, dimension rows included:
value == mean(judges.score)With one judge they are equal; with a panel,
valueis the mean andjudges.scoreshows the spread — so per-dimension disagreement is readable from the dimension row instead of by unpacking the map on.overall.
Upgrading: notEmpty(judges.name) now matches every judge row rather than only <grader>.overall, so a six-case run goes from 12 judged rows to 48. Anything aggregating over judges must filter to .overall or it multiplies by the dimension count. That is a change in results, not an error.
2.4.1
What's Changed
The judge's identity is now in its provenance pin.
Shipped as a patch. Both changes below alter a published value, so the 1.0.0 policy would call this a minor at least; it goes out as 2.4.1 as a deliberate exception, because the old pin was answering a provenance question wrongly and the sooner it stops the fewer runs are affected.
Fixed
-
Scorecard.judge_versionnow includes the judge's model:anthropic:claude-sonnet-4-6@v1rather thanjudge@v1.It was
key@judge_version. Swapping the judge's model while leaving the declaredjudge_versionalone produced a byte-identical pin, so any provenance check reading that field passed a comparison against a baseline scored by a different model:before the swap: judge@v1 after the swap: judge@v1 ← same pin, different judgeNow:
before the swap: anthropic:claude-sonnet-4-6@v1 after the swap: anthropic:claude-opus-4-1@v1The model is the thing most likely to change and the thing a declared version is most likely to miss.
Changed
- A single judge's key defaults to its provider (
anthropic) instead of the literaljudge. A panel already defaulted tokey or provider, so the one-judge case was the odd one out, andjudgenamed nothing thegradercolumn did not already say. It appears in thejudges.namecolumn.
Upgrading: every suite with a judge re-baselines once, because judge_version is part of what identifies a comparable run — the intended behaviour, just paid all at once. Queries filtering judges.name = 'judge' need the provider instead. Set key: on a judges: entry to pin a name of your own.
2.4.0
What's Changed
A run without a baseline can now say whether it passed.
Added
-
RunResult.checks, amodels.ThresholdCheckthe runner fills in: the suite's absolute guardrails (min/max) measured against this run. Most of a suite's rules are absolute and answerable from one run; only the win metric and the relative rules need a baseline.There is nothing new to call.
compareis for comparing, and a single run was never a comparison — so the runner does it, the same way it already recordsgraders.verdict: pass [ok] errors - 0.0000 ok [ok] length_ok - 1.0000 ok [skipped] cta_mentions_offer - must_not_decrease not evaluated: needs a baseline -
compare.check_thresholds(scorecard, thresholds), which the runner uses.
Changed
-
Breaking:
GuardrailResult.passedis nowbool | None.Nonemeans the rule could not be evaluated — amust_not_increase/must_not_decreasewith no baseline. Testpassed is Falsefor a breach;not passednow also catches the skipped case. The Markdown and HTML reporters render it asskippedrather than a breach. -
store.score_rowsreportsrun.checkson the gate columns when noComparisonis passed:gate_verdictand the per-metricguardrailbecome real, whilegate_winstays'none'.That split is the point.
gate_winis what marks the threewin_*fields as never computed rather than measured at zero, so an ungated run reports a real verdict without claiming a comparison happened. A rule that could not run reportsguardrail = 'none'with the reason inguardrail_gap, never a falsepass. -
A
Comparisonsupersedes the run's own checks: same rules, plus a baseline, so it gets the relative ones too.
Upgrading: if you read GuardrailResult.passed, change not g.passed to g.passed is False.
2.3.0
What's Changed
A run now describes its own scores.
Added
-
RunResult.graders, a map of grader name tomodels.GraderInfo(category,scale), filled in by the runner from the graders it built.A
Scorenames the grader that emitted it and nothing else, so a run could not previously answer for itself. Arun.jsonwritten by one CI step and published by a later one needed the suite file alongside it; without one, every grader reportedunknownand a suite with a judge could not be published at all. The artifact looked complete and wasn't.Run-grain rather than score-grain: one entry per grader, not two fields repeated across a couple of hundred rows.
-
models.GraderInfo. -
registeralso records the category on the decorated class asgrader_category, so a grader instance can answer for itself. The runner reads it the same way it already collectsjudge_version.
Changed
-
store.score_rowstakes the grader category and judge scale fromrun.graders:exporter.export_scores(run, comparison) # no lookups needed
grader_typesandjudge_scalesstill override it, and are how a run written before this release - whose map is empty - publishes correctly.grader_lookupsis unchanged.
Upgrading: nothing to do. Passing the lookups still works and still wins.
2.2.0
What's Changed
Added
-
store.ScoreExporter, aruntime_checkableProtocol naming the seamJsonlOutboxExporteralready occupied.store.pyhas always said to "replace this class with a database client implementing the sameexport_scoresmethod", but the contract was a docstring sentence and**kwargs, so an implementation had to duck-type a private shape.An exporter belongs in the package that owns the store it targets - it is the store that knows its own column types, null policy, and transport. Swapping one for another is a constructor line at the call site, so an offline run and a live one share a code path:
exporter = store.JsonlOutboxExporter(path) # offline exporter = KafkaOutboxExporter(brokers) # live, from another package exporter.export_scores(run, comparison, grader_types=..., judge_scales=...)
Fixed
examples/quickstart/graders.pystill used the one-argumentregisterand raisedTypeErroron import, so 2.1.0 shipped with its own bundled example broken.just testdoes not run the example;just test-alldoes.- The README's Python API example called a
.export()that does not exist and passedRunResults tocompare.compareandrender_scorecard, which takeScorecards. It now runs verbatim, and showsgrader_lookupsfeeding the exporter.
Upgrading: nothing to do. Both additions are backward compatible.
2.1.0
What's Changed
A grader declares what kind of check it is at registration, so a consumer plug-in is categorised the same way a built-in is. Previously any grader outside the seven built-in types reported grader_type = 'unknown' on every row it produced.
Shipped as a minor despite the signature change below. register is public, so the 1.0.0 policy would call this a major; it goes out as 2.1.0 as a deliberate exception, because the break is a one-line edit per grader that fails loudly at import.
Added
graders.GraderType, aStrEnumover the closed set the results store'sgrader_typecolumn accepts:unknown,heuristic,statistical,llm_as_judge,trajectory,human. AStrEnumso it needs no serializer of its own on the way to a row.graders.category_of, the registry lookup behindgrader_lookups.
Changed
- Breaking:
graders.base.registertakes a required second argument,category, agraders.GraderType. Every@base.register('foo')becomes@base.register('foo', base.GraderType.HEURISTIC)or whichever member applies; omitting it is aTypeErrorat import. Required rather than defaulted on purpose - it is the only source of a row'sgrader_type, and a default would be the value every grader forgets to override. store.grader_lookupsreads the category from the registry instead of a closed table of built-in type names, so a plug-in that declaresHEURISTICreportsheuristicwhere it used to reportunknown. Rows for consumer graders change value in thegrader_typecolumn; nothing about the row shape changes.
Removed
store._GRADER_TYPES, the private table the categories used to live in. Keeping it alongside the registration argument would mean two sources for one fact and a precedence rule between them.
Upgrading
Add the category to every @base.register(...) in your plug-ins:
from evalcore.graders import base
@base.register('my_grader', base.GraderType.HEURISTIC)
class MyGrader:
...Pick the member that describes the check, not the grader's shape - an AggregateGrader computing a rate is still HEURISTIC, not STATISTICAL.