Evaluators
An evaluator scores the (source_output, target_output) pair for one example. Every evaluator returns a PairedScore with both halves and a delta = target_score − source_score. Negative deltas mean the target regressed; positive deltas mean it improved.
EvalShift ships eight evaluator keys — structural, semantic, llm_judge, tool_selection, tool_arguments, tool_trace_structure, agent_trace and trace_invariants — grouped here by what they measure:
Structural (deterministic, free)
Fast checks that depend only on the output shape — no API calls.
- +
json_schema— does the output parse as JSON and validate against a schema you provide? 1.0 / 0.0. - +
regex— does the output match a pattern? 1.0 / 0.0. - +
length— is the output within[min_chars, max_chars]? 1.0 inside, distance-decayed outside.
Use these whenever you can — they’re free, deterministic, and cover most real regressions.
Semantic (cheap)
- +
semantic.cosine— embed both outputs with a configurable embedding model, compute cosine similarity, then frame the result as a target-preservation score: source = 1.0, target = similarity. Delta < 0 means the target drifted in meaning from the source; a target belowmin_similarity(default 0.9) is what counts as a regression in the policy rates.
Drift inside min_similarity is no change. Because the source is pinned at 1.0, any rewording is a small negative delta, so a row within the threshold counts as equivalent in the budgets and enters the paired significance test as a delta of 0; only a row below it carries its full drop into severity. delta_avg_score still shows the raw drift. See methodology.
Formatting is not drift either. Both outputs are normalized before embedding: a code fence wrapping the whole output is dropped, and a JSON object or array is re-serialized canonically (sorted keys), so a fenced, pretty-printed object and the same object returned compact score 1.0. A fence inside prose is part of the answer and kept.
The default embedding_model is text-embedding-3-small, which is OpenAI’s and needs OPENAI_API_KEY. evalshift init writes this evaluator active but advisory for every provider; Anthropic and DeepSeek have no embeddings endpoint, so it borrows OpenAI’s or Gemini’s, whichever key you already have. What happens when the key is missing: see Blocking vs advisory.
Use when:
- +You don’t have a clean structural check.
- +You want to detect “wandered off” outputs that still look fine syntactically.
Don’t use when:
- +The target is intentionally meant to differ from the source (e.g. you’re migrating from a verbose model to a terse one). The similarity will look low and you’ll get a confusing “regression” signal.
LLM-as-judge (most expensive)
- +
llm_judge.<criterion>— ask a strong model “which output better satisfies this criterion?” with random A/B ordering to reduce positional bias. Verdict maps cleanly to (source, target) scores.
Use when you can articulate the difference you care about as a sentence (“which output preserves more factual detail?”). Multiple llm_judge entries are allowed — each becomes its own evaluator. Tool-only turns (both outputs empty) are skipped without spending a judge call.
Pick the judge from a third model family. A judge prefers output that reads like its own (self-preference bias), and A/B randomisation does nothing against that. doctor and validate warn when an llm_judge judge_model resolves to the same provider as defaults.source_model or target_model; the report repeats the note above the verdict for judges that actually contributed rows, and report.json carries it as judge_family_overlap. Advisory only, never a failure — init scaffolds a same-provider judge on purpose so a first run needs only your provider’s key (on Anthropic and DeepSeek, semantic borrows another provider’s embeddings and is skipped until that key is set — see Blocking vs advisory). defaults.judge_model is exempt: it only seeds the run-insights model, never a pairwise verdict.
Tool-call evaluators (agent migrations)
For examples whose toolset is non-empty (toolset_ref or inline tools), EvalShift parses each model’s response into a provider-agnostic ToolTrace and scores three orthogonal dimensions:
- +
tool_selection— which tools fire? Two independent axes, one record each.conformancegrades each side absolutely against the example’s ground truth:expected(default;expected_toolsmatched in order),expected_set(order-insensitive multiset recall, for parallel fan-outs),off.divergencegrades the target against the source:set(default; Jaccard on tool names, so reordered identical calls don’t read as drift),exact,first,off. A conformance row both sides missed is taggedTOOL_GROUND_TRUTH_MISS— a broken harness, not a migration finding — and leaves the policy rates. Configureseverity_floor: highso a regression here can never be downgraded. - +
tool_arguments— what did the model pass? Greedy match by(tool_name, sequence_index), then per-field strategies (exact/subset/numeric/semantic/auto). Fields no strategy names usedefault_strategy, which defaults toauto: normalized exact first, then the field’s schema type, then graded similarity for free text. Use when arg drift matters (e.g. the model still callsissue_refundbut the amount is wrong).against: source(default) measures drift from the current model, which pins its own score at 1.0 by construction;against: expectedscores both models against the recorded ground truth instead. - +
tool_trace_structure— how did it sequence them? Sub-scores: call count, parallelism, refusal alignment, expected count. Refusal mismatches forceseverity_floor: high.
Per-round scoring. When an example carries tool_result_fixtures (a suite promoted with --rounds all), run replays it teacher-forced and each of the three scores per round: tool_selection conformance grades round k against expected_tool_rounds[k] (“called nothing” for the answer round) and divergence compares round k of the target to round k of the source; tool_arguments pairs calls within a round, so a right call in the wrong round is a miss; tool_trace_structure counts calls over the whole trace and compares parallelism round by round, recording details.rounds_replayed. Each record is the mean over replayed rounds with the per-round names and scores under metadata.rounds, so max_tool_divergence counts an example as diverged when any round diverged; a round with no ground truth in which neither side called anything does not enter the mean. Single-shot pairs score exactly as before and carry no rounds key. See Agent rounds.
Each agent failure mode maps to one of the three: dropped tool / wrong tool → tool_selection; argument drift → tool_arguments; parallel↔serial flip, loop divergence, refusal regression → tool_trace_structure. Start with tool_selection; turn the others on once you’ve decided you care.
Imported agent traces
If your agent runs its loop outside EvalShift — LangChain, a custom orchestrator, another language — attach the timelines to a completed run with evalshift traces import and score them with the agent_trace evaluator: tool-order similarity (LCS normalised), per-field argument equality, and missing-verification checks, with verification_tools and dangerous_tools naming the calls that matter. Extra dangerous calls on the target flag DANGEROUS_ACTION_DRIFT; a dangerous call with no preceding verification flags MISSING_VERIFICATION_STEP. Or hold both sides to rules of your own with trace_invariants and traces: imported.
Trace invariants
The relative tool checks — divergence, argument drift with against: source, and dangerous_tools — treat “the source did it” as correct: they measure the target against the source. trace_invariants judges both sides against rules your team wrote — forbidden, required, order, call_count and arguments — on replayed traces or, with traces: imported, on traces attached with evalshift traces import. Each side scores the share of rules it broke zero times, and a rule the target breaks counts against it whatever the source did. On a blocking rule set those breaches feed the max_invariant_violations budget, which fails the run even when the suite is too small for statistics.
Write rules only for invariants where a silent miss is expensive: authenticate before a write, never call a deprecated endpoint, charge at most once, keep an amount inside its bounds. Production traces remain the broad corpus.
Keep the recording and the rules apart. Captured expected_tools are the recording, and conformance: expected grades their order as recorded. An ordering that matters belongs in an order rule, not in the recording. The fields, rule types and their exact semantics are in evaluators.trace_invariants.
Blocking vs advisory
Every evaluator takes blocking: bool (default true). Advisory evaluators still score and still appear in reports, but never move the migration verdict — which is what a fresh evalshift init writes for semantic and llm_judge, so a new suite cannot fail a build on evaluators nobody has calibrated yet. If every evaluator is advisory the verdict is inconclusive, with the exception of the cost and latency budgets: those read the run’s calls rather than evaluator records, so a conclusive breach still fails.
When nothing gates the verdict, the recommendations say whether each advisory judge is ready to gate: once it has scored at least 20 pairs on every prompt they tell you to set blocking: true on it; until then they name the thinnest prompt’s count. A judge that measured nothing on a prompt (every call errored, say) is told so instead, since more examples would not help. semantic is never suggested — it measures drift, not correctness.
Missing API keys. semantic and llm_judge are the evaluators that call a model of their own. Since CLI 1.4.0, compare, run and evaluate check each one’s API key before the first model call — including the defaults a hand-written entry falls back to: text-embedding-3-small (OpenAI) for semantic, and gemini-3.1-flash-lite-preview for a judge with no judge_model.
- +An advisory entry without a key is skipped, with a line naming the env var to export. The run records it in
state.jsonasskipped_evaluatorsand repeats it in the verdict’s recommendations, the report and the bundle. A skippedsemanticalso degradestool_arguments: free-text arguments are compared with difflib instead of embeddings, andsemantic-strategy fields fall back to exact matching. - +A blocking entry — any
semanticorllm_judgethat does not sayblocking: false— stops the run with exit 1. So does asemanticmarked advisory whose embedder a blockingtool_argumentsentry uses for asemantic-strategy field; the error names that entry. - +A config whose every evaluator would be skipped exits 1 as well: nothing would score the run.
- +
doctorgives the same answer in itsevaluator keysrow — fail for a blocking gap, warn for an advisory one.
Before CLI 1.4.0, a keyless judge or embedder failed on every pair, the run landed on inconclusive and passed --policy-gate, so a CI job that was green can turn red on upgrade. Export the key, or set blocking: false to have the evaluator skipped with a warning. A model on a provider EvalShift does not recognise is not checked.
A turn that ends in tool calls has no text. semantic and llm_judge mark such rows skipped rather than scoring them a 0.5/0.5 tie, and skipped rows are excluded from the paired test, the slice aggregates and the policy metrics. A comparison with nothing left reports as unknown (“Nothing measured”), never as equivalent.
Mixing evaluators
You can configure several at once. They all run on every (prompt, example) pair (trace_invariants only on prompts its applies_to matches), and the analysis layer treats each as a separate comparison (so BH correction adjusts for the multiple-test count correctly).
A typical migration uses:
- +1–2 structural evaluators (cheap baseline checks)
- +1
semantic.cosine(catches semantic drift) - +1
llm_judgeper criterion the team cares about
Cost considerations
Per (prompt, example) pair, each evaluator means:
| Evaluator | Cost |
|---|---|
structural.* | $0 (no calls) |
semantic | 2 embedding calls |
llm_judge | 1 judge model completion |
tool_selection | $0 (compares parsed traces only) |
tool_trace_structure | $0 (compares parsed traces only) |
agent_trace | $0 (compares imported timelines only) |
trace_invariants | $0 (checks traces against your rules) |
tool_arguments | $0 without an evaluators.semantic block; with one, embedding calls for free-text argument values (the default auto strategy grades them by meaning) and for any semantic-strategy field |
A 100-example suite with 1 prompt and 4 evaluators (2 structural + 1 semantic + 1 judge) is:
- +Run: 200 model calls (100 × 2 models)
- +Evaluate: 200 embedding calls + 100 judge calls
LiteLLM’s pricing data drives the pre-flight estimate; the local SQLite cache absorbs identical re-runs, examples with a toolset included, and the embedding and judge calls are cached too.
Want the statistical machinery behind delta-aggregation? See methodology.
