GuidesSweep decisions

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

WhereCost & Quality → Agent model → open a sweep → Compare arms
Key API routesGET /model-sweeps/{id}/comparison, GET /model-sweeps/{id}/arms/{arm_id}/regressions
AuthRead-only. The same nk_live_ agent API key that drives the sweep
CLIneens sweep decide --sweep-id … (TypeScript: neens-eval sweep-decide --sweep-id …)
NeedsA sweep whose arms have finished. Nothing else — no LLM call, no configuration
ProducesOne 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

ArmModelPass rate (n=30)pass^kCost per caseRegressions
incumbent (baseline)claude-sonnet-4-696.7% · CI 83.3–99.4%3/3$0.00067—
cost-optimized (winner)claude-haiku-4-593.3% · CI 78.7–98.2%3/3$0.000212
challengergpt-4o-mini93.3% · CI 78.7–98.2%3/3— (unpriced)2
previous-genclaude-sonnet-4-586.7% · CI 70.3–94.7%2/3$0.000684 (2 flaky)
budgetgpt-oss:120b33.3% · CI 19.2–51.2%0/3$0.0020
self-hostedgpt-oss:20bno data0/3 · failed——

Read the last three rows before the first three, because each is a trap this page exists to avoid:

  • challenger has 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 $0 would have handed it the win.
  • budget is the cheapest arm there is (a real, catalogue-priced $0 self-hosted model) and loses anyway, because the ranking is quality-first.
  • self-hosted reports 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

AgentItemsVerdict
refund-agent22cost-optimized clears the 90% bar at $0.0002 per case (100% pass rate, n=22) — 68.3% cheaper than incumbent
escalation-agent8incumbent 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:

Precedencebar.sourceWhere it comes from
1query?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
2gateThe sweep’s own pinned gate min_pass_rate — the bar you committed to when you launched
3defaultThe 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:

FieldQuestion it answers
clearsBar.clearsDoes the point estimate reach the bar? This is what the leaderboard shows
clearsBar.clearsLowerBoundDoes 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/3 reports passed: false and 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 flakyItems instead.

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:

signalMeaning
issue_classThe regressed sessions carry classifier issue labels — the failure modes shown are those
clusterNo issue labels, but the sessions belong to failure clusters — the cluster labels are used
noneNeither. 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:

  1. failureModes — the pinned signal’s labels with counts, each linking to the failure mode when the signal resolved to real taxonomy rows, plus unclassified for the rest.
  2. items — up to limit (default 50, max 200) failing golden prompts: the frozen input and expected output, the baseline’s and candidate’s passedRuns/scoredRuns, the candidate’s score, the answer it actually gave, and the judge’s reason.
  3. sessionIds on 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 --json

Flags

FlagMeaning
--sweep-id IDRequired. Unlike sweep get, the id is a flag, not a positional
--bar 0.9The 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 IDThe arm to measure savings and regressions against. Default: position 0
--require-winnerExit non-zero unless an arm clears the bar. Off by default. Implies --wait
--waitPoll until the sweep is terminal before comparing
--poll-interval / --poll-timeoutPoll cadence (default 10s) and total budget (default 3600s)
--jsonEmit the full machine-readable result on stdout; the human table still goes to stderr

Exit codes

CodeWhen
2The 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
0Anything else, by default — including no arm clears the bar. Naming a winner is a decision, not a gate
1With --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 exportedThe 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 redactedFree 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 exportedThe regression drill-down. Golden inputs, model answers, judge reasons and session ids are not part of the shared payload at all
LifetimeDefault 168 hours (7 days), capped at 720 hours (30 days). Revocable at any time, and served no-store + noindex
FreshnessMinting 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)

ToolDoes
get_model_sweep_comparisonReads 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=

FieldMeaning
comparable / voidReasonfalse ⇒ the sweep is void and no ranking is drawn
bar{passRate, source} — source ∈ query · gate · default
baseline{armId, label, kind} — the declared incumbent
passK / itemCountRepeats per arm, and frozen golden prompts
arms[]Per arm, below
agents[]{agent, label, itemCount, arms[], verdict} — one row per agent name, plus Unattributed last
verdictThe 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:

FieldMeaning
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
distinguishableFromBaselinetrue · 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
outcomereasonCodeMeaning
winnercheapest_clearingThe cheapest priced arm that clears the bar
none_clearno_arm_clearsArms were measured; none reached the bar. Names the best observed rate
none_clearno_priced_armArms clear the bar, but none has a resolved price, so none can be named cheapest
unavailableno_barNo quality bar was supplied
unavailableno_dataNothing 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:

CodeMeaningUI wording
not_terminalStill running, or pass^k has no verdict yetstill running
no_dataNothing scoredno scored items
failed_pass_kDid not pass every one of its k runsdid not pass pass^k
below_barMeasured, terminal, green — but under the barbelow the quality bar
unpricedNo resolvable price, so it cannot be ranked on costits 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
LimitDefaultNotes
Captured sessions folded into one comparison20000Over 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 request50, max 200 (limit)totalRegressions is always the true total

Troubleshooting

SymptomCauseFix
An arm shows — in the cost columnIts model has no price in your catalogueSet 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 sizeAdd 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 notSame fix — more items. The claim is real, it is just not yet proven
Every agent row says UnattributedThe captured traces carry no agent nameSet an agent name on your traces — see Send traces
The drill-down shows no failure modesThe regressed sessions are unclassified — the normal state for a fresh sweepRead the failing items directly; they are listed. Classification arrives with clustering and issue labels
No verdict, comparable: falseThe sweep is voidFix what drifted and run a fresh sweep. A void sweep is intentionally not rankable
sweep decide exits 2 on a finished sweepVoid, failed, or the API call erroredThe reason is printed above the exit — void prints its void reason
The Share (redacted) button is missingShare links are off for this agentTurn them on for the agent with PUT /share-links/settings (requires an admin)