EvalShift
← all posts
migration·Sep 16, 2026·8 min read

This one passed: 71% cheaper, and the agent still does the same thing

An EvalShift migration report that said PASS: a 71% cheaper model inside every policy budget. Where each threshold came from, and what PASS does not claim.

The previous post was about a migration that was cheaper, faster and still failed. This is the other outcome. It is the more common one once a suite is in decent shape, and I see it written up far less often, because a pass is boring.

It shouldn't be. A pass is only worth something if the limits were fixed before the run. So this post is the whole thing: the policy, the reasoning behind every number in it, and the EvalShift report it produced.

For anyone arriving here cold: EvalShift is a local-first CLI for testing LLM model migrations. It replays a frozen golden suite against your current model and a candidate, scores every pair of outputs with tool-call, structural, semantic and LLM-judge evaluators, runs paired statistics over the deltas, and turns a migration_policy block you wrote in evalshift.yaml into one of four verdicts: pass, conditional_pass, fail or inconclusive. It writes a single-file report.html on your machine; nothing is uploaded unless you run evalshift push. Every figure in this post is a panel from that report.

The candidate: a customer-support agent with six tools (lookup_customer, lookup_order, check_refund_policy, issue_refund, escalate_to_human, search_kb), moving from gemini-3.7-pro to gemini-3.7-flash. The suite: 120 examples recorded in production by the EvalShift capture SDK, promoted into a golden JSONL suite with evalshift capture sync, and sliced by tag according to what the conversation was about.

SliceExamplesWhat is in it
routine42order status, shipping, account questions
refund26refund and return requests
security24account access, password and payment-method changes
customer_lookup16requests that need a customer record first
text_only12greetings, thanks, off-topic

One evalshift compare command, real API calls on both sides, and the report opened on:

Migration verdict
PASS

10 of 10 budgets within policy.

Equivalent 90.1%Improved 6.3%Regressed 3.6%
Advisory signal
3.6%regression rate

Below the max_overall_regression_rate of 5%. 16 of 444 scored comparisons, none above medium severity.

Economics
-71.2%cost

$1.9248 → $0.5544. Latency -61.9%. Both inside +0% cost / +30% latency.

EvalShift's verdict card. Ten budgets, all held; the regression rate and the economics sit beside it so nobody has to scroll to find out what the pass cost.
Examples120
Calls2400 cached
Failed / truncated0 / 0
Total cost$2.4792
Latency Δ-61.9%
Avg score Δ+0.011
The run strip. Same 120 examples on both sides, nothing failed or truncated, so every comparison below is over the full suite. EvalShift excludes truncated and errored calls from the statistics, so this row is worth checking first.

Cheaper by 71.2%, faster by 61.9%, verdict PASS. The economics look like the last post's. The difference is everything that was decided before the run.

## The numbers were written before the run

EvalShift's migration_policy is a block in evalshift.yaml with seven budgets: overall regression rate, critical regression count, equivalent-or-better rate, tool-argument drift, tool-selection divergence, cost increase and latency increase. A slices map under it lets any slice override any budget, inheriting the top-level value where it doesn't. Evaluators are configured per suite; budgets are set once and tightened per slice.

evalshift init --profile cost-reduction scaffolds a starting policy: 2% overall regression rate, zero critical regressions, 97% equivalence, 1% tool-argument drift, 5% cost increase, 30% latency increase. That is a starting point, not a decision. This is what the project actually ran with:

yaml
migration_policy:
  max_overall_regression_rate: 0.05
  max_critical_regressions: 0
  min_equivalence_rate: 0.90
  max_tool_argument_drift: 0.10
  max_tool_divergence: 0.05
  max_cost_increase: 0.0
  max_latency_increase: 0.30
  slices:
    security:
      max_overall_regression_rate: 0.0
      max_tool_divergence: 0.0
    refund:
      max_tool_argument_drift: 0.0

Three things moved between the profile and this file.

### Check every rate against the suite size

A rate over n rows can only move in steps of 1/n. This suite has 108 tool-argument rows, so one drifted row is 0.93%, and a 1% budget is a zero-tolerance budget wearing a percentage. EvalShift prints a recommendation when a budget is below the granularity of its denominator, naming the budget, the value and the row count, and this one would have triggered it.

So the rule I use: if I mean zero, I write 0.0. Where I don't, I set a number the sample can resolve. 10% argument drift on 108 rows is ten reworded search queries, which is what drift on this agent looks like in practice.

### Tolerance where wording lives, zero where money and access live

The overall regression rate went up, from 2% to 5%. The pairwise LLM judge is blocking on this project, and a judge flips on rewording; 2% of 444 comparisons is nine judge calls deciding the migration. 5% leaves room for the judge to disagree about phrasing without anything real being allowed through.

The strictness moved into the slices instead. security gets zero regressions and zero tool-selection divergence: a model that starts routing an account-access request to a different tool does not get a percentage. refund gets zero argument drift: an order id or an amount that drifts is a wrong refund, not a reworded one.

The slices that are not in the policy matter too. customer_lookup has 16 examples. EvalShift tests a comparison below 20 pairs but flags it uncertain, so that slice inherits the top-level budgets and gets no tighter ones. It is the slice I would grow before I tightened it.

### The cost budget is the reason for the migration

max_cost_increase: 0.0. The point of the exercise is to spend less. A candidate that costs more has failed before any quality number is read, and a policy should say so instead of leaving it to whoever reads the economics card. EvalShift measures cost and latency from the run's own calls, so these two budgets gate even when no quality evaluator does.

Two evaluator decisions sit alongside the budgets. Every EvalShift evaluator carries a blocking flag; advisory (blocking: false) results are reported and ranked but never change the verdict. The judge is blocking: true here; init writes false because at a dozen examples judge noise would decide the verdict, and at 120 with an audited criterion it earns its vote. semantic stays advisory. It measures wording, and wording is the one thing this migration was allowed to change.

## What the run measured

BudgetScopeObservedLimit
Overall regression ratemax_overall_regression_rateoverall3.6%16 of 444 · 95% CI 2.2–5.8%≤ 5.0%
Critical regressionsmax_critical_regressionsoverall0of 444≤ 0
Equivalent-or-better ratemin_equivalence_rateoverall96.4%428 of 444≥ 90.0%
Tool-argument driftmax_tool_argument_driftoverall4.6%5 of 108 tool-argument rows≤ 10.0%
Tool-selection divergencemax_tool_divergenceoverall2.8%3 of 108 divergence rows≤ 5.0%
Cost increasemax_cost_increaseoverall0.0%cost fell 71.2%≤ 0.0%
Latency increasemax_latency_increaseoverall0.0%latency fell 61.9%≤ 30.0%
Overall regression ratemax_overall_regression_ratesecurity0.0%0 of 96≤ 0.0%
Tool-selection divergencemax_tool_divergencesecurity0.0%0 of 24≤ 0.0%
Tool-argument driftmax_tool_argument_driftrefund0.0%0 of 26≤ 0.0%
Every budget against its limit, as EvalShift reports them. Seven at the top level, three on the two slices where a regression is a wrong action rather than a reworded one.

The first row is the one worth reading twice. 16 of 444 is 3.6%, and its 95% Wilson interval runs from 2.2% to 5.8%. The interval crosses the limit.

EvalShift's rule is asymmetric on purpose. A breach fails only when the interval confirms it; a breach the interval cannot confirm returns inconclusive, because the suite was too small to say. A budget the observation held is conclusive however wide its interval, because a wide interval must never downgrade a clean run. This budget held, so it passes, and the interval is printed so the reader knows how much room there was.

The three slice budgets held at exactly zero. Zero on 96 comparisons means something. Zero on 12 would not, which is why text_only has no slice budget at all.

## By evaluator

Four evaluators scored this run. EvalShift's tool_selection evaluator reads the recorded traces and scores two axes: conformance, where each side is graded against the suite's recorded tool calls, and divergence, where the target is graded against what the source did. tool_arguments scores argument values field by field against the expected call. The llm_judge is pairwise and sees the two outputs as anonymous A and B. semantic is embedding similarity between the two outputs. The first three are blocking; the last is advisory.

EvaluatornScore ΔEffect95% rangeConf.Result
Routing — conformancerouting · tool_selection.conformanceeach side graded against the suite's recorded tool calls108+0.0460.28small[0.09, 0.47]Likely✓ ImprovedTarget scores higher than source.
Routing — divergencerouting · tool_selection.divergencethe target graded against what the source did108-0.0280.17negligible[-0.36, 0.02]Unclear✓ EquivalentNo meaningful difference between models.
Routing argsrouting_args108-0.0040.03negligible[-0.22, 0.16]Unclear✓ EquivalentNo meaningful difference between models.
LLM judge: equivalencellm_judge.equivalence120+0.0290.12negligible[-0.06, 0.30]Unclear✓ EquivalentNo meaningful difference between models.
Semantic similarityadvisorysemantic120-0.0410.44small[-0.62, -0.26]Likely✗ Regressed — mediumReported, not gating: blocking is false.
Overall, by evaluator. Each row is a paired test over that evaluator's deltas: effect size with a 95% interval, and a confidence label from the Benjamini-Hochberg corrected p-value. Four blocking rows say equivalent or improved; the one regression is on the advisory evaluator that measures wording.

Four blocking rows say equivalent or improved. The one that regressed is advisory, and it measures the one thing this migration was allowed to change. Had semantic been blocking, the same run would have come back conditional_pass on a medium-severity regression in phrasing. That is not a reason to make every evaluator advisory. It is a reason to decide, per evaluator, whether what it measures is something you are willing to block a migration on.

The conformance row says the candidate matched the suite's recorded tool calls more often than the model that produced them: 11 examples improved, one regressed. Most of the eleven were refund requests where the source went straight to issue_refund.

## The diffs I still read

A pass is not permission to skip the diff. For every flagged example, the EvalShift report shows the reason it was flagged, the tool calls on each side, the argument-level diff, and the conversation context that led to it. Two examples from this run, one from each side of the ledger.

cap_6b1e40f2a9c34d0b8e7d2a5f31c9e804turn 1Routing — conformance+0.500
Why flaggedsource 0.50 → target 1.00 (0–1)IMPROVED

The suite expected check_refund_policy before issue_refund. The source went straight to issue_refund; the target called both, in order.

Tools calledissue_refund→check_refund_policy, issue_refund
Source tracescore 0.500
  1. issue_refund ({"order_id": "ord_58213", "amount": 42.0, "reason": "damaged on arrival"})

final text:Done — I've refunded $42.00 to your original payment method. You'll see it in 3–5 business days.

Target tracescore 1.000
  1. check_refund_policy ({"order_id": "ord_58213"})
  2. issue_refund ({"order_id": "ord_58213", "amount": 42.0, "reason": "damaged on arrival"})

final text:Your order is inside the 30-day window, so I've refunded $42.00 to your original payment method. Expect it in 3–5 business days.

Tool diffs
  • extra tool: Target added check_refund_policy at position 1.
  • matched: issue_refund arguments are identical on both sides.
An improvement, as EvalShift's per-example diff shows it. The recorded ground truth expected the policy check first; the source model that produced the traffic skipped it and the candidate did not.

The refund went out either way, and the final text on both sides would pass any output check you care to write. The trace is where the difference lives: one side verified before acting and the other did not. This is the class of change a text evaluator cannot see in either direction, and the reason EvalShift's tool-call evaluators score the trace rather than the prose.

cap_9d27c5e1b04a4f6e9a3c7d8e2f105b6cturn 0Routing args-0.160
Why flaggedsource 1.00 → target 0.84 (0–1)ARGUMENT_VALUE_DRIFT

search_kb.query scored 0.84 against the expected value under the semantic strategy, below the 0.9 drift floor. Same tool, same intent, different wording.

Tools calledsearch_kb→search_kb
Source tracescore 1.000
  1. search_kb ({"query": "change shipping address after order placed"})

final text:You can change the address until the order ships — open the order and tap “Edit address”. Once it's marked shipped, reply here and I'll reroute it with the carrier.

Target tracescore 0.840
  1. search_kb ({"query": "edit delivery address existing order"})

final text:Until the order ships you can edit the address yourself from the order page. If it has already shipped, let me know and I'll set up a carrier reroute.

Tool diffs
  • argument drift: search_kb.query scored 0.84 against the expected query (drift floor 0.9).
  • same tool set: no calls added or removed.
A regression the budget was built to tolerate. Five of 108 tool-argument rows drifted like this one; the limit was ten.

A reviewer would call those the same query. The evaluator scored it 0.84 against a 0.9 floor and counted it as drift, five rows out of 108 did the same, and the budget said five is fine. That is what the 10% was for. The same five rows in the refund slice would have failed the run, and that was also decided in advance.

## What EvalShift did in this run

The whole run, as a list of the product's parts, in the order they were used:

  • +The capture SDK recorded the agent's real conversations in production, tool calls included, and evalshift capture sync promoted them into a frozen golden JSONL suite with the tool evaluators written from what the captures actually contained.
  • +evalshift compare replayed every example against the source and the target model, paired per example, with the same inputs, tools and context on both sides.
  • +The tool-call evaluators (tool_selection, tool_arguments) scored the traces, the pairwise LLM judge scored the outputs, and semantic measured drift in wording, advisory only.
  • +Paired statistics turned each evaluator's deltas into an effect size, a 95% interval and a corrected confidence label, so a two-point average drop and a real regression are told apart mechanically.
  • +migration_policy in evalshift.yaml held seven budgets, three of them tightened to zero on the security and refund slices, and every proportion budget was judged with a Wilson interval that can return inconclusive instead of a false fail.
  • +report.html was written locally, verdict first, with a per-example diff for everything flagged. Nothing left the machine.
  • +--policy-gate made the verdict an exit code, which is what the EvalShift GitHub Action uses to block a pull request on the same policy once the suite runs in CI.

## What a pass proves

Not that nothing changed. 16 comparisons regressed and 28 improved; the agent phrases search queries differently and checks the refund policy more often than it used to.

It proves that every change stayed inside limits that were written down before anyone saw a number, on a suite that was frozen before the run. That is the entire claim, and it is enough to act on, because there is nothing left to negotiate: the argument about what counts as acceptable happened in the YAML, not in the meeting after the report.

What happens next is the boring part, which is the point. The model string changes in production. The suite does not. It runs again on the next pull request through the EvalShift GitHub Action, gated on the same policy, against the new baseline.

bash
evalshift compare --suite-name support_routing --to gemini-3.7-flash --policy-gate --open

If the previous post was the reason to run the comparison, this one is the reason to write the policy first.