oryxeval changelog¶
Every release and what changed in it. Newest first; versions follow calver (YY.M.D).
All notable changes to oryxeval are recorded here. This file is read by humans and by AI coding agents diagnosing regressions after an upgrade, so the format is load-bearing:
- Newest first. One
## [version] - YYYY-MM-DDheading per release; version is calverYY.M.Dmatchingpyproject.toml/oryxeval.__version__. Unreleased work goes under## [Unreleased]. - Group bullets under
### Added/### Changed/### Deprecated/### Removed/### Fixed/### Security(Keep a Changelog: https://keepachangelog.com/). - Every breaking change is a bullet that STARTS with the literal token
BREAKING:and carries a same-bulletMigration:clause with the old->new fix. - Name the actual symbol in backticks (
`sweep`,`EvalResult.verdict()`), never prose. Agents grep this file for the symbol in their traceback.
[26.11.6] - 2026-11-06¶
Added¶
oryxeval: first release as a standalone package. The code isoryxflow.evalsas of oryxflow 26.10.22, unchanged apart from the import name. Requiresoryxflow>=26.11.6. Migration: change only the import lines,import oryxflow.evals as ev->import oryxeval as evandfrom oryxflow.evals.<module> import X->from oryxeval.<module> import X; existing eval caches then stay valid (no cache key contains the module path). Keepimport oryxflowand leave every function body as it is:code_version(),case()and their helpers are hashed into the cache key, so any edit there (evenoryxflow.hash_files->ev.hash_files) re-runs every arm. The next run reuses the cache but prints StalenessWarnings; clear them once, fromevals/:python -c "import oryxflow; oryxflow.accept_code()"(theaccept_code(<Eval>)the warning names covers only the scoring stage). New evals useev.hash_files.oryxevalre-exports oryxflow'sParameter,IntParameter,FloatParameter,BoolParameter,ChoiceParameter,ListParameter,DictParameter,DateParameter,EnumParameterandhash_files(the same objects).
Fixed¶
pyyamlis a declared dependency (load_casesreads.yamlfiles).oryxeval.cliandoryxeval.build_appimport and resolve on first use as documented.
Before the split (shipped as oryxflow.evals)¶
The entries below are from oryxflow's changelog, verbatim, under their original versions.
[26.10.5] - 2026-10-05¶
Added¶
oryxflow.evals: a cell is now two cached stages -- the model calls, then the scoring.TaskEvalgenerates an<Eval>Outputstask (TaskJson, one record per case x repeat: output, inputs, timing, metrics, attributes) that the scoring task requires. Editing an evaluator, changing a judge rubric or swapping the judge model re-scores the stored outputs with ZERO calls to the model under test; editing what the arm reads re-runs both. Before, onecode_version()keyed both, so a scorer change either re-billed every call or served stale scores. A subclass'scode_version()keeps its meaning (what the ARM reads) and now keys the model-call stage only; the newTaskEval.scorer_version()keys the scoring stage and defaults to every evaluator's code and configuration (as_spec()). A typed output is rebuilt withmodel_validate, soctx.output.attrscorers are unchanged. Evaluators that read the run's span tree (HasMatchingSpan,ToolCorrectness,TrajectoryMatch,ArgumentCorrectness,MaxToolCalls,MaxModelRequests, or any withneeds_trace = True) score in the model-call stage, because spans are not stored.oryxflow.evals:sweep(rescore=True)/ev.cli --rescore-- re-run only the scorers over the stored outputs (after a transient judge failure, say). The bill printsre-scoring N arms from stored outputs: no model callsand counts an LLM judge's calls separately.oryxflow.evals:baseline=onsweep()(or abaselineattribute on aTaskEval; default: an arm namedbaseline). The verdict then reports every arm's paired difference from it with its interval, and names no winner -- mixed results need a written judgement, not a pass/fail.oryxflow.evals:EvalResult.side_by_side(path=None, all=False, template=None)-- every arm's output per case as markdown, by default only the cases where the arms disagree on a column the verdict reads, plus failures. Rendered with Jinja from the stored outputs (no model call);template=takes a path or a string.oryxflow.evals:load_cases()also takes a list of row dicts, routed exactly like a file's rows -- for a probe that keeps a handful of cases inline.oryxflow.evals:ev.cli --side-by-sidewritesEvalResult.side_by_side()to the eval module's ownresults/<date>-<arms>-side-by-side.md, wherever the run was launched from.oryxflow.evals:ev.cliwarns when launched from a directory other than the eval script's own -- the cache and relative paths resolve against the working directory, so such a run reads and writes a different cache.oryxflow.evals— an LLM-eval harness over pydantic-evals, installed with the newevalsextra (pip install oryxflow[evals]; pydantic-evals, scipy, typer). Nothing in it is imported byoryxflow/__init__.py, so a user without the extra is unaffected; import it explicitly asimport oryxflow.evals as ev. pydantic-evals keeps scoring — cases, evaluators, repeats, concurrency, retries, the success/failure split — and oryxflow adds the matrix and the cache: one parquet per arm, so a re-run of an unchanged arm makes zero calls and editing the target function or the case file re-runs only the cells that read it.ev.sweep(target, cases=..., metric=..., **arms)runs the grid and returns anev.EvalResult(.df,.verdict(),.report(),.best()).targetmay be a plain async function — no class and no scaffold for a first eval — or anev.TaskEvalsubclass. Any keyword holding a list is an axis. The projected call count, the cached/new split and a cost estimate print before anything is billed, and a swept parameter the function cannot accept raises rather than running N identical cells labelled as branches.watch=(glob patterns, or a callable) folds files the target reads — a prompt template, a.sqlfile — into the cache key, so editing one re-runs the arms that read it; without it a prompt sits outside the key entirely and an edit re-runs nothing.ev.TaskEvalis the class form (aTaskPqPandaswithpersists = ['cases']): declaredataset,metric,guardrail,slicesand onecase()coroutine.preflight()makes one real call before the sweep, so a credential failure costs one call instead of a matrix; abovemax_failure_rate(default 20%) the run raisesev.EvalFailureRateErrorand saves nothing, because a parquet of exceptions is indistinguishable from a measurement.concurrencyissignificant=False, so it cannot fragment the cache.ev.load_cases(path, inputs=..., expected=...)reads cases from.csv/.yaml/.jsonl, routing columns by name; a@path/to/file.mdvalue is replaced by that file's text.syntheticcases are reported beside the headline and never in it, andholdoutcases are withheld from every arm with the count stated.ev.Metric(label, column, where=, higher_is_better=, budget=, coverage=)is the number a sweep is judged on.ev.rate_ci/ev.delta_ci(scipy, clustered by case) put a confidence interval on it. The verdict names no winner when the delta interval spans zero, never says "wins" unqualified when a guardrail budget broke, flags a metric that is identically 0% or 100% in every arm, and flags a denominator that moved between arms.ev.git_tree(ref, paths, dest=, expect_absent=)materializes a baseline arm from a git ref (byte-exact, and the only mechanism that can restore a deleted file);ev.git_sha(ref)is the resolved SHA to use as that arm'scode_version().ev.Variant(old, new)is the anchored-replacement probe for a rewrite that has not shipped, and raises when its anchor is gone rather than silently becoming a no-op.ev.cli(TaskEvalSubclass)builds a Typer command line whose flags are derived from the task's Parameters, so adding a parameter adds a repeatable flag and the two cannot drift. Built-ins:--repeats --concurrency --reset --check --csv --yes;--checkrunspreflight()only and exits.ev.cli/ev.build_appresolve on first use, soimport oryxflow.evalsdoes not pay for Typer.
Changed¶
oryxflow.evals: theevalsextra now requirespydantic-evals>=2(for the tool-call evaluators,CaseLifecycleand Windows-safe report output) and addsjinja2(forside_by_side()).oryxflow.evals:reset=True/--resetdiscards BOTH stages, model calls included. Userescoreto re-run only the scorers.oryxflow.evals: the function form's arm identity now also follows what the target CALLS (codehash.callable_code_hash), not only the target's own body -- editing a project-local helper or prompt loader re-runs the arms that use it.oryxflow.evals:repis read from pydantic-evals' own"<name> [i/N]"naming when outputs are stored, instead of being re-derived from row order (TaskEval._assign_repsis now a no-op kept as an override point).oryxflow.evals: judge validation.ev.judge_alignment(df, judge, human)reports a judge's TPR, TNR and Cohen's kappa against human labels, andev.corrected_rate(observed, alignment)applies the Rogan-Gladen correction -- the standard estimator for a proportion measured with an imperfect test, which is what an LLM judge is. Declare it on the metric (ev.Metric('pairing', 'pairing_holds', human='pairing_human')) and the verdict prints the alignment and the corrected rate per arm. Kappa is reported and labelled as the one to read, because raw agreement inflates under class imbalance: on a 90%-pass set a judge that passes everything scores 90% agreement and has learned nothing (kappa 0). The correction is REFUSED whenTPR + TNR <= 1, where the estimator's denominator collapses and the "correction" is amplified noise. This matters beyond tidiness: an imperfect judge pulls every rate toward its own error floor, so it shrinks the very difference a sweep exists to detect -- a true 85% vs 55% reads as 78% vs 60% through a judge that is 88% accurate.oryxflow.evals: a guardrail inside budget on the point estimate but not on its interval now says so (the interval for prod reaches 25% -- at this sample size the guardrail is not demonstrated, only not yet violated). At n=12,0% (0/12)is entirely consistent with a true rate above a 5% budget, and the bound is the thing to act on.oryxflow.evals: a lone arm scoring 100% is flagged as saturated rather than reported as a win -- a case set that never fails cannot show a regression or an improvement, so the response is harder cases. (Multi-arm saturation was already caught by the dead-metric check.)oryxflow.evals:ev.PromptArm-- where one arm READS its prompt files and the cache key that MATCHES them, in one object (.dir()and.code_version()). Written by hand those are two places that must agree, and when they drift the failure is silent: a baseline arm reading a checked-out tree but keyed on a hash of the LIVE files re-runs when a live prompt is edited, and does not re-run when it is pointed at a different ref. A working-tree arm is keyed on the content of itsglobs, arefarm on the resolved sha; both hand back the same layout viasubdir=, so a loader never has to know which arm it got. An arm with no key at all is refused at construction.oryxflow.evals: the cost confirmation now offers the one-call preflight at the prompt --Run 56 new calls for X? (y/n/c=check one call first).cspends one call, reports whether the wiring works, and asks again. A--checkflag only helps someone who already knows it exists, and the person who needs it most is running the eval for the first time. Deliberately still offered rather than run automatically before the bill: a tool that bills money should not make its first call unasked, however cheap.oryxflow.evals:sweep()acceptsevaluators=and passes them to the dataset it generates. The target function is handed onlyinputs, so scoring against a case'sexpected_output— the shape every classifier eval has — previously required building aDatasetby hand and passing bothcases=anddataset=.oryxflow.evals: a sweep axis with a single value no longer appears in the arm name. It is identical in every arm, so it added width to every row of every table and distinguished nothing:model_id=['gemini-3.5-flash'], prompt_version=['prod','preship']now names the armsprodandpreshiprather thanmodel_id_gemini-3.5-flash_prompt_version_prod. When no axis varies the full name is kept, since a lone cell calleddefaultsays less.oryxflow.evals:verdict()no longer reprints the call-count header thatsweep()already emitted as the bill, and "Weakest slices" lists only slices actually below the arm's own rate — a 100% slice under that heading reads as a warning about something that is fine.
Fixed¶
oryxflow.evals: aTaskEvalsubclass now re-runs when its case set changes. Only the function form folded the cases into its cache key, so editingcases.csvunder the class form served the previous cases' results.oryxflow.evals: under pandas 3 the<field>_previewcolumns were never written, because text columns get astrdtype instead ofobject.oryxflow.evals: the per-case frame was discarding most of what the report carried._to_frame()read only assertions, scores, metadata and the output, so six things went on the floor. Now columns:case.metrics(whatincrement_eval_metricrecorded -- token counts, retries, anything a task measures about its own call),case.attributes(set_eval_attribute-- a resolved model snapshot, say),case.expected_output(flattened toexpected/expected_*),case.labels,case.total_duration, andreport.report_evaluator_failures. Theexpected_*recovery is the one that changes how code reads: a confusion matrix or a per-label breakdown is now a crosstab of two frame columns instead of joining the answer key back on by case name.oryxflow.evals:EvalResult.verdict()reports measured usage when the task recorded it --measured usage: input tokens 9,612, output tokens 2,720-- read from the conventionalinput_tokens/output_tokens/total_tokens/costcolumns. Before this, cost could only ever be the pre-run estimate, because the real numbers pydantic-evals collected were being thrown away. Reported by name rather than inferred from any numeric column: guessing which column is a token count eventually sums something that is not one.oryxflow.evals: an evaluator that raised is no longer indistinguishable from one that declined to score.ReportCase.evaluator_failureswas read by nothing, so a judge crashing on every case (a missing API key, say) left its column absent and the metric reportedn/awith no hint that anything was wrong. The frame now carriesevaluator_errors/evaluator_errorand the verdict prints them above the rate.case.labelswas likewise dropped and is now a column like assertions and scores.oryxflow.evals: a synccase()(or a syncsweep()target) no longer silently serializes the run.concurrencyis scheduled as coroutines by pydantic-evals, so a blocking case holds the event loop and the cases run one at a time while the setting still reads as honored -- same numbers, ~5x the wall clock (8 half-second cases atconcurrency=4: 1.0s async, 4.9s sync). It now emits aRuntimeWarningnaming the task and the ignored concurrency. Nothing about oryxflow itself is async; the requirement comes from the eval runner, andconcurrency=1is the quiet way to opt out.oryxflow.evals: the dead-metric flag no longer fires on a guardrail sitting inside its budget in every arm.0%false-positives on both sides is the outcome a guardrail exists to confirm, and reporting it as "this usually measures the harness" teaches the reader to ignore the one line that matters when it is real. A guardrail identically over budget is still flagged.oryxflow.evals: declining the cost confirmation on theev.clipath exits 0 with a one-line message instead of raising through to a traceback. Choosing not to spend is the outcome the confirmation exists to make easy.oryxflow.evals: aMetricno longer counts an unmeasured row as a failed one. A row whose metric column is null (an LLM judge that grades only some cases, or one switched off with--judge-model none) is now excluded from the rate exactly as an errored row is, instead of being coerced toFalse. Previously that both deflated the numerator and padded the denominator: a prompt passing 2 of 2 judged rows out of 6 reported33%, and a metric with no verdicts at all reported a confident0%with a confidence interval.Metric.compute()now counts only measured rows, a wholly unmeasured metric returnsNaNand rendersn/a, andMetric.unmeasured()/ the verdict state how many rows carried no verdict. NewMetric.eligible()is the denominator before that filter.oryxflow.evals:sweep()no longer silently overwrites a case metadata column namedarm(or one named after a sweep axis) with its own cell label.armis the natural word for a dataset that compares, say, anoutlinearm with aguidelinesarm, and the tag destroyed it: anyslices=('arm',)then reported the arm label back with no error and a plausible-looking table. The clash is knowable from the cases alone, so it now raises before any call is made, naming the key and the fix.armjoinsRESERVED_METADATA.