Sweep decisions
A model sweep produces evidence: N models × k runs over one frozen golden dataset. The comparison turns that evidence into the answer — “for the refund agent the cheap model clears your 90% bar at a fraction of the cost; for the escalation agent it does not.”
That second half is the point. “A cheaper model still hits the number” is almost never true of a whole workspace; it is true of one agent and false of another, and a decision made on the workspace average quietly ships the wrong model to the work that matters most.
At a glance
| Where | Cost & Quality → Agent model → open a sweep → Compare arms |
| Key API routes | GET /model-sweeps/{id}/comparison, GET /model-sweeps/{id}/arms/{arm_id}/regressions |
| Auth | Read-only. The same nk_live_ agent API key that drives the sweep |
| CLI | neens sweep decide --sweep-id … (TypeScript: neens-eval sweep-decide --sweep-id …) |
| Needs | A sweep whose arms have finished. Nothing else — no LLM call, no configuration |
| Produces | One verdict sentence, an arm leaderboard, a verdict per agent, a regression drill-down, and an optional redacted share link |
Nothing here is stored, and nothing is spent. Every number is computed on read from the sweep’s own child runs, the traces they captured and your price table. Opening the page reconciles the sweep first, so a still-running sweep is current — and re-reading it after an admin corrects a model’s price gives you the corrected figure immediately.
The whole thing in one worked example
make demo-stack seeds a real sweep you can open and follow along with: Support agent — model
selection, six arms over 30 frozen golden items spanning two agents — a refund-agent
(22 items: lookups, policy answers, multi-step resolutions) and an escalation-agent (8 items:
fraud, chargebacks, safety, over-limit money — cases the agent must not settle alone), each arm
run k = 3 times.
Open the comparison
Cost & Quality → Agent model → Support agent — model selection → Compare arms.
Read the verdict, first and largest
cost-optimized clears the 90% bar at $0.0002 per case (93.3% pass rate, n=30). That is 68.5% cheaper than incumbent. 2 other arms also clear the bar and are not distinguishable from it at n=30. At n=30 the 95% confidence interval reaches below the bar, so this sample does not prove it.
Four separate claims, and the last two are the ones a chart would have hidden: two other arms are just as good as far as this sample can tell, and the sample is not yet large enough to prove the winner clears the bar.
Audit it against the leaderboard
| Arm | Model | Pass rate (n=30) | pass^k | Cost per case | Regressions |
|---|---|---|---|---|---|
| incumbent (baseline) | claude-sonnet-4-6 | 96.7% · CI 83.3–99.4% | 3/3 | $0.00067 | — |
| cost-optimized (winner) | claude-haiku-4-5 | 93.3% · CI 78.7–98.2% | 3/3 | $0.00021 | 2 |
| challenger | gpt-4o-mini | 93.3% · CI 78.7–98.2% | 3/3 | — (unpriced) | 2 |
| previous-gen | claude-sonnet-4-5 | 86.7% · CI 70.3–94.7% | 2/3 | $0.00068 | 4 (2 flaky) |
| budget | gpt-oss:120b | 33.3% · CI 19.2–51.2% | 0/3 | $0.00 | 20 |
| self-hosted | gpt-oss:20b | no data | 0/3 · failed | — | — |
Read the last three rows before the first three, because each is a trap this page exists to avoid:
challengerhas no price, so it shows an em dash and is excluded from the cost ranking — even though it clears the bar. Treating its missing rate as$0would have handed it the win.budgetis the cheapest arm there is (a real, catalogue-priced$0self-hosted model) and loses anyway, because the ranking is quality-first.self-hostedreports no data, not 0%. Its endpoint was unreachable, so nothing was scored. We do not know how that model performs, and 0% would claim we do.
Then read the per-agent section — this is where the answer changes
| Agent | Items | Verdict |
|---|---|---|
refund-agent | 22 | cost-optimized clears the 90% bar at $0.0002 per case (100% pass rate, n=22) — 68.3% cheaper than incumbent |
escalation-agent | 8 | incumbent clears the bar at $0.0010 per case (100% pass rate, n=8). cost-optimized drops to 75% and does not clear |
The workspace-level winner is the cheap model. The decision is not “switch to the cheap model” — it is “you can drop to the cheap model on the refund agent and not on the escalation agent.” That sentence is only available because the sweep’s golden set covers both agents.
Ask where it breaks
Click cost-optimized’s regression count. The drill-down groups the 2 regressed items by a failure signal and names it: both land in the failure mode Escalation policy not applied, and each failing item shows the frozen input, the expected output, what this arm answered and the judge’s reason. The seeded cheap arm states the refund limit and then approves the refund anyway — a genuine small-model instruction-following failure you can read in its own words.
A verdict is a decision, not a gate. Neens names the cheapest arm that clears your bar; it never switches a model, opens a PR, or changes a deployment. Shipping stays a human action.
Set the quality bar
A bar you did not choose is a bar you should not trust, so the comparison reports where its bar
came from — bar.source on the wire, and a line under the verdict in the UI:
| Precedence | bar.source | Where it comes from |
|---|---|---|
| 1 | query | ?bar= on the request, or the Quality bar (pass rate %) control on the page. In the UI it is a percentage; on the wire it is a 0–1 rate, and anything outside that range is a 422 |
| 2 | gate | The sweep’s own pinned gate min_pass_rate — the bar you committed to when you launched |
| 3 | default | The deployment’s success threshold, 0.90 by default |
Pinning {"min_pass_rate": 0.9} on the sweep’s gate at launch is the durable option: everyone who
opens the comparison later reads it against the same number, with nothing to configure and no
query parameter to remember. That is what the seeded demo does.
The baseline arm
Savings, regressions and new passes are all measured against one arm — the declared
incumbent, which is the arm at position 0 (the first one you listed when you created the sweep).
Override it with ?baselineArm=<armId> or the Baseline arm control.
Neens never picks the best-performing arm as the baseline. A baseline chosen after seeing the results is not a baseline, it is a flattering comparison.
How to read the honesty signals
Every one of these exists because the alternative is a number that reads like a fact and isn’t.
Sample size and confidence interval
Every rate carries its n and a 95% Wilson confidence interval, and an arm that this sample
cannot separate from the winner is annotated “not distinguishable at n=…” rather than silently
ranked above or below it.
94% and 91% over 40 items are not different. At those sample sizes the intervals overlap heavily, and the ordering you see is sampling noise wearing a ranking’s clothes.
Two bounds are reported, and they answer different questions:
| Field | Question it answers |
|---|---|
clearsBar.clears | Does the point estimate reach the bar? This is what the leaderboard shows |
clearsBar.clearsLowerBound | Does the interval’s lower bound reach the bar? This is the claim that survives scrutiny |
In the worked example the winner clears on the point estimate and not on the lower bound — which is why the verdict says so out loud. The fix is more golden items, not a rounder number.
pass^k, and why 2 of 3 is not a pass
Two independent rules, both of which have to hold:
- Per arm: the arm must be green on all k of its runs. An arm at
2/3reportspassed: falseand is excluded from the ranking — see pass^k. - Per item: an item counts as passed only when every scored repeat of it passed. An item
that passed 2 of 3 identical attempts has not passed; it is counted in
flakyItemsinstead.
Averaging the flaky items into the rate would re-introduce exactly the lucky green that k repeats
exist to catch. In the worked example previous-gen carries 2 flaky items — the same prompts, a
different answer on one repeat.
Unpriced arms — the one to read twice
An unpriced arm shows —, never $0.00, and can never win “cheapest.”
If a model has no rate in your price catalogue, Neens does not know what it costs. Treating that as free would make it the cheapest arm in every comparison it ever appears in — the single most dangerous thing this page could get wrong.
So an unpriced arm is still reported, still shown as clearing (or not clearing) the quality bar,
still listed among the arms that clear — with costPerCaseUsd: null — and also listed under
excluded with the reason unpriced, because it cannot be ranked on cost. The page’s pricing
block comes back partial: true and names the models.
Fix it in one place: set a rate for that model under Settings → Model pricing, then reload
the comparison. A self-hosted model you pay GPU time for is a real $0 — enter it once and it
becomes a legitimate winner, which is a different thing entirely from having no rate at all.
A partly-priced arm is reported the same way: pricedCases versus cases tells you how much of
the arm the figure covers, and unpriced sessions are excluded from the mean rather than counted as
zero.
Unattributed agents
The per-agent split groups items by the agent name on the captured trace. Items whose sessions carry no agent name group under a row labelled Unattributed — never folded into a named agent, because “we do not know which agent this was” and “it was the refund agent” are different facts, and merging them moves items (and a verdict) onto an agent that never ran them.
If your whole workspace lands under Unattributed, your agent isn’t setting an agent name on its traces — see Send traces. A genuinely single-agent workspace legitimately renders one row; that is honest, not a bug.
Unclassified regressions
The drill-down picks one failure signal for the whole response and says which:
signal | Meaning |
|---|---|
issue_class | The regressed sessions carry classifier issue labels — the failure modes shown are those |
cluster | No issue labels, but the sessions belong to failure clusters — the cluster labels are used |
none | Neither. The sessions are unclassified |
One signal, never a blend, so the same regression can never be counted twice under two names. And
regressed sessions that carry no classification are reported in an explicit unclassified count —
never dropped, because hiding them shrinks the denominator until the modes that are shown look
like the whole story.
signal: "none" is the normal answer for a fresh sweep. Pre-prod sessions are frequently
unclassified — nothing has labelled them yet. The panel says so and lists the failing items
directly, rather than showing an empty taxonomy or inventing modes.
A void sweep refuses to name a winner
If a sweep’s arms did not run under identical conditions it is void,
and the comparison declines: comparable: false, verdict.outcome: "unavailable", no ranking, no
winner. The arms’ own numbers are still shown — they are facts about each run — but no cross-arm
claim is drawn from them, in the UI, in the CLI, or in the export.
There is deliberately no way to “just show it anyway”. A comparison of arms that measured different things is the most convincing wrong answer this feature could produce.
”No data” is never 0%
An arm with nothing scored reports rate: null and renders no data. An arm still running is
excluded as not_terminal rather than judged on the runs it happens to have finished.
Where the cheap model breaks
Clicking an arm’s regression count opens the drill-down, or call it directly:
curl -sf "$NEENS_BASE_URL/model-sweeps/{sweep_id}/arms/{arm_id}/regressions?limit=50" \
-H "Authorization: Bearer $NEENS_API_KEY"A regression is precisely: the baseline arm passed this item on every one of its k runs, and
this arm — having been scored at least once — did not. An item this arm never captured is missing
evidence, not a regression, and is not counted. The inverse is reported too, as newPasses.
The response gives you three levels, in the order you want them:
failureModes— the pinned signal’s labels with counts, each linking to the failure mode when the signal resolved to real taxonomy rows, plusunclassifiedfor the rest.items— up tolimit(default 50, max 200) failing golden prompts: the frozen input and expected output, the baseline’s and candidate’spassedRuns/scoredRuns, the candidate’s score, the answer it actually gave, and the judge’s reason.sessionIdson each item — the captured traces themselves, for a full trace view.
totalRegressions is the true total and truncated says when the item list was cut to limit;
the per-mode counts always describe the whole regression set, not just the page.
Decide from CI
neens sweep decide reads the comparison and prints the server’s verdict — the same sentence,
the same leaderboard, the same per-agent verdicts the UI renders. The ranking is never re-derived
client-side: two rankings that can disagree is worse than one.
export NEENS_BASE_URL="https://neens.example.com"
export NEENS_API_KEY="nk_live_..."
# Decide a finished sweep against its own pinned bar.
neens sweep decide --sweep-id msw-…
# Or against a bar you set here, comparing to a specific incumbent, and wait for it to finish.
neens sweep decide --sweep-id msw-… --bar 0.9 --baseline-arm msa-… --wait
# Fail the build unless some arm clears the bar.
neens sweep decide --sweep-id msw-… --bar 0.9 --require-winner --jsonFlags
| Flag | Meaning |
|---|---|
--sweep-id ID | Required. Unlike sweep get, the id is a flag, not a positional |
--bar 0.9 | The pass rate an arm must clear, 0–1. Anything outside that range is rejected before a request is made. Omit it to use the sweep’s pinned gate, then the deployment default |
--baseline-arm ID | The arm to measure savings and regressions against. Default: position 0 |
--require-winner | Exit non-zero unless an arm clears the bar. Off by default. Implies --wait |
--wait | Poll until the sweep is terminal before comparing |
--poll-interval / --poll-timeout | Poll cadence (default 10s) and total budget (default 3600s) |
--json | Emit the full machine-readable result on stdout; the human table still goes to stderr |
Exit codes
| Code | When |
|---|---|
| 2 | The sweep is void / comparable: false, or the sweep failed, or a transport/API error. Checked first, before any verdict — a void sweep still has per-arm numbers, and reading those first is exactly how an untrustworthy comparison gets to look like a pass |
| 0 | Anything else, by default — including no arm clears the bar. Naming a winner is a decision, not a gate |
| 1 | With --require-winner: no arm cleared the bar, or the sweep has not finished (you cannot require a winner from a sweep you have not watched finish) |
Use pre-prod evals and eval gates to block a
release. Use sweep decide to choose a model. --require-winner exists for the narrow case
where “at least one model in this list is shippable” really is a build condition.
Gate on cost, not just quality
A sweep’s pinned gate — and any pre-prod run’s gate — accepts a cost family, evaluated
server-side, spelled exactly as the SDK’s client-side cost rule spells it:
{
"max_regressions": 0,
"min_pass_rate": 0.9,
"cost": { "max_delta_pct": -50 }
}That gate is literally “cheaper and still hits the number”: every child run must hold a 90% pass rate and come in at least 50% below the baseline’s average cost per session. See the server-side cost gate for the full rule set, and for the rule that matters most: an unpriced run is reported skipped, never passed.
Share the decision
The comparison exports as a read-only, redacted, expiring public link — the artifact you paste into the doc that justifies a model switch, without granting anyone access to Neens.
Press Share (redacted) on the comparison page, or:
curl -sf -X POST "$NEENS_BASE_URL/share-links" \
-H "Authorization: Bearer $NEENS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"resourceType": "sweep_decision", "resourceId": "msw-…", "ttlHours": 168}'It must be enabled for the agent before it shows anything: turn on shareable links for the
agent — PUT /share-links/settings with {"enabled": true}, which requires an admin. Until
then the button is hidden rather than broken, and minting a link returns 403.
| What is exported | The whole comparison envelope: the verdict, every arm’s rate, CI, pass^k, latency, cost and pricing provenance, the per-agent verdicts, and the pricing/warnings blocks |
| What is redacted | Free text — the verdict sentence, arm labels, agent names, per-run failure reasons — passes through the same PII/secret redaction the shared trace and cluster links use. Numbers, model ids and provenance pass through intact, because a redacted rate is a useless artifact |
| What is never exported | The regression drill-down. Golden inputs, model answers, judge reasons and session ids are not part of the shared payload at all |
| Lifetime | Default 168 hours (7 days), capped at 720 hours (30 days). Revocable at any time, and served no-store + noindex |
| Freshness | Minting reconciles the sweep once, so the link starts current. The public page then renders it as of that state and never triggers a write — for the terminal sweep anyone actually shares, that is the final state |
Drive it from a coding agent (MCP)
| Tool | Does |
|---|---|
get_model_sweep_comparison | Reads the comparison back: the verdict, the per-arm leaderboard, the per-agent verdicts, the pricing block and any warnings. Optional bar and baseline_arm. Read-only — computes nothing new and spends nothing |
The tool description hands the agent the same four rules a human reads: comparable: false means
void so never rank; costPerCaseUsd: null means unpriced, never free; rate: null means no data,
not 0%; and an item counts as passed only when it passed every repeat. See MCP server.
Reference
Comparison response
GET /model-sweeps/{sweep_id}/comparison?bar=&baselineArm=
| Field | Meaning |
|---|---|
comparable / voidReason | false ⇒ the sweep is void and no ranking is drawn |
bar | {passRate, source} — source ∈ query · gate · default |
baseline | {armId, label, kind} — the declared incumbent |
passK / itemCount | Repeats per arm, and frozen golden prompts |
arms[] | Per arm, below |
agents[] | {agent, label, itemCount, arms[], verdict} — one row per agent name, plus Unattributed last |
verdict | The decision, below |
pricing | {partial, unpricedModels, version} — partial: true means at least one figure is incomplete |
warnings[] | Anything that limits the read (a capped session scan, a reconcile that could not run) |
Per arm:
| Field | Meaning |
|---|---|
passK | {passed, k, greens, runs[], reason} — runs are the sweep’s real child pre-prod runs |
quality | {n, successes, rate, ciLow, ciHigh, ciLevel, marginOfError, scoredRuns, flakyItems, unscoredItems}. n counts items with at least one scored repeat; successes counts items that passed every repeat |
clearsBar | {bar, clears, clearsLowerBound, margin} — all null when the rate or bar is missing |
distinguishableFromBaseline | true · false · null. false = this sample cannot tell the two apart |
latency | {n, avgMs, p95Ms} over the captured sessions |
cost | {cases, pricedCases, costPerCaseUsd, totalUsd, priced, partial, unpricedModels, pricing}. costPerCaseUsd is the mean over priced sessions — what serving one case costs, not k× it. totalUsd is what the sweep actually spent on the arm |
regressions | {count, flaky, newPasses, vsArmId}. The baseline arm reports zeros with vsArmId: null |
Verdict outcomes and reason codes
outcome | reasonCode | Meaning |
|---|---|---|
winner | cheapest_clearing | The cheapest priced arm that clears the bar |
none_clear | no_arm_clears | Arms were measured; none reached the bar. Names the best observed rate |
none_clear | no_priced_arm | Arms clear the bar, but none has a resolved price, so none can be named cheapest |
unavailable | no_bar | No quality bar was supplied |
unavailable | no_data | Nothing comparable — including a void sweep |
clearing[] is sorted cheapest-first with unpriced arms last; tiedWith[] lists arms that clear
the bar and are statistically indistinguishable from the winner; savingsVsBaselinePct is null
when either side is unpriced.
Among priced clearing arms the order is fully deterministic: cheapest cost per case, then higher pass rate, then lower arm position, then arm id. The same evidence always produces the same winner — a verdict that flips between refreshes is not a decision.
Why an arm was excluded
Evaluated and reported in this order — the first applicable reason wins:
| Code | Meaning | UI wording |
|---|---|---|
not_terminal | Still running, or pass^k has no verdict yet | still running |
no_data | Nothing scored | no scored items |
failed_pass_k | Did not pass every one of its k runs | did not pass pass^k |
below_bar | Measured, terminal, green — but under the bar | below the quality bar |
unpriced | No resolvable price, so it cannot be ranked on cost | its model has no price, so it cannot be ranked on cost |
An arm that is both below the bar and short of pass^k reports failed_pass_k, because that
disqualifies it regardless of its rate. Both facts stay visible on the arm’s own row (passK and
clearsBar), so the exclusion code is never the only thing you have.
Limits
| Limit | Default | Notes |
|---|---|---|
| Captured sessions folded into one comparison | 20000 | Over the cap, cost, latency and the per-agent breakdown cover the newest 20000 sessions and a warnings entry says so. Pass rates and regressions still cover every item |
| Regression items per request | 50, max 200 (limit) | totalRegressions is always the true total |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
An arm shows — in the cost column | Its model has no price in your catalogue | Set a rate under Settings → Model pricing, then reload. It is unknown, not free |
| The verdict says arms are “not distinguishable” | The confidence intervals overlap at this sample size | Add golden items. There is no threshold that makes a small sample decisive |
| The winner clears the bar but “this sample does not prove it” | The point estimate clears; the interval’s lower bound does not | Same fix — more items. The claim is real, it is just not yet proven |
| Every agent row says Unattributed | The captured traces carry no agent name | Set an agent name on your traces — see Send traces |
| The drill-down shows no failure modes | The regressed sessions are unclassified — the normal state for a fresh sweep | Read the failing items directly; they are listed. Classification arrives with clustering and issue labels |
No verdict, comparable: false | The sweep is void | Fix what drifted and run a fresh sweep. A void sweep is intentionally not rankable |
sweep decide exits 2 on a finished sweep | Void, failed, or the API call errored | The reason is printed above the exit — void prints its void reason |
| The Share (redacted) button is missing | Share links are off for this agent | Turn them on for the agent with PUT /share-links/settings (requires an admin) |
Related
- Model sweeps — how the evidence is produced, and what
voidmeans - Cost & model pricing — where a cost per case comes from, and how to price a model
- Model comparison — the same question asked of production traffic
- Pre-prod evaluations — the child runs a sweep launches, and the cost gate
- Issues & failure modes — the taxonomy the drill-down groups by
- MCP server — reading the verdict from a coding agent