Model comparison
Swapping an agent onto a cheaper model is easy to justify on cost and hard to justify on quality.
Neens closes that gap: every score records which model produced the answer it graded and
how that was known, so eval_pass_rate can be broken down per model — and per agent × model —
from the same judges you already run in production.
The other half of the argument is on Cost & model pricing: that page tells you what each model costs, this one tells you whether it still hits the bar.
At a glance
| What it is | Model + agent attribution recorded on every score, so the quality measures slice by model |
| Where it lives | The Quality tab of the widget gallery on any dashboard — presets Pass rate by model, Pass rate by agent × model, Scores by model attribution |
| Measures it applies to | eval_pass_rate, avg_score, score_count (see the metrics catalogue) |
| New dimensions | Model, Agent, and Model attribution at score grain |
| Key API routes | GET /measures/catalogue, POST /dashboards/{id}/widgets, GET /dashboards/{id}/widgets/{wid}/data |
| Needs | Traces that record the model on their LLM spans, and at least one judge producing scores. No LLM call, no configuration. |
Attribution is recorded, not inferred at read time. The model is stamped onto the score row when the score is written. That is what makes a comparison reproducible: a pre-prod run’s model is frozen when the run is created, so “Haiku passed at 94% in June” keeps meaning the same thing after somebody edits the connection in July.
Answer: “does the cheaper model still hit our bar?”
Open a dashboard and add the preset
On the Dashboards page, open (or create) a board scoped to the agent you’re comparing, click
Add widget, and pick the Quality tab. Drop in Pass rate by model — one click adds it
fully configured (eval_pass_rate grouped by Model).
Set the window
The board’s time picker drives the tile (Today · Last 24h · 7 days · 30 days · All time · Custom). Pick a window that covers both models — a comparison over a window where the new model only ran for the last two days is a comparison of sample sizes, not of models.
Read the bars
Each bar is one model id, and its value is the share of scores in that window that met their own threshold. Two bars you may not have expected are drawn with a hatched fill, and a legend under the chart says why:
- Mixed — traces whose LLM spans named more than one model.
- Unknown — scores with no attributable model.
Neither is folded into a real model’s bar, and neither is dropped — dropping them would raise the apparent pass rate of the models that remain.
Check the sample behind each bar
Add a second tile from the Custom tab — measure Total scores, dimension Model — or drop in the Scores by model attribution preset. A 100% pass rate over eleven scores is not evidence.
Decide with the cost half
Put a Spend by model tile (from the Cost tab) on the same board. The claim you can now make is a two-tile claim: this model costs X and passes at Y. See Cost & model pricing.
Narrow to one metric before you quote a number. eval_pass_rate grouped by model averages
across every metric that scored in the window — a helpfulness judge and a safety judge are not the
same bar. Slice or filter by Metric (metric_key) so the two models are compared on the same
question. The same applies to Score source: pre-prod runs write scores too, so a production
comparison should filter to the sources you mean.
Where a score’s model comes from
Every score carries a Model attribution value naming the evidence used. It is a closed vocabulary, listed here in precedence order — the first one that resolves wins:
| Attribution | Shown as | The model is | When |
|---|---|---|---|
preprod_run | Frozen on the run | the model frozen on the pre-prod run when it was created | The score came from a pre-prod evaluation whose agent connection declared a model. The strongest evidence there is: it records what the candidate was configured to run, not what a trace happened to look like. |
span | From the scored span | that span’s own model | The score graded a single span rather than a whole trace. |
session_uniform | Uniform across the trace | that one model | Every model-bearing span in the graded trace named the same model. |
mixed | Mixed | the Mixed bucket | The graded trace’s spans named two or more distinct models. |
unknown | Unknown | nothing | No model could be derived: the trace recorded none, or the score predates model attribution. |
The left column is the value stored on the score and used in API filters; the middle column is how it’s worded on a chart.
Only spans that actually name a model vote. A tool call or a retrieval step carries no model, so it never supplies one and never makes a trace look “mixed”.
preprod_run beats the trace. If a pre-prod run froze a model, its scores attribute to that
model even when the captured trace’s spans say something else — the run row is the record of what
you deliberately tested. A pre-prod run with no declared model falls through to the captured trace’s
own spans; it is never given an invented one.
Reading Mixed and Unknown — and what to do about them
These are the two buckets people want to make disappear. Both are answerable.
Mixed
Mixed means the graded trace ran on more than one model — a supervisor on one model with workers on another is the common shape. Neens does not pick a winner between them (see why), so the trace gets its own bucket and the score row keeps the list of distinct models it saw.
What to do:
- If it’s expected (a genuine multi-model pipeline), compare at a level where the answer is single-model: score spans, or split the pipeline into separate agents and use the agent × model cross-tab below.
- If it’s not expected, it usually means a fallback or retry silently switched models. The Agent Map keys its LLM nodes by model, so a trace that used two shows two nodes — that’s the fastest way to see which step defected.
Unknown
Unknown means no model was derivable. There are two causes and they have different fixes:
| Cause | Fix |
|---|---|
The traces don’t record a model. Your instrumentation never set gen_ai.response.model / gen_ai.request.model / llm.model_name on the LLM spans. | Fix the instrumentation — see Which attributes Neens reads. This also fixes cost, which reads the same field. |
The scores predate model attribution. Scores written before this feature existed were stamped unknown and deliberately not guessed at. | These stay Unknown — they are never re-derived retroactively. Exclude them from comparisons; scores written from now on are attributed automatically as they land. |
Until then, exclude them: a pass rate computed over rows whose model you don’t know is not a statement about any model.
Filter to the attributions you trust
Model attribution exists so you can do exactly that. Add the Scores by model attribution preset first — it answers “how much of this comparison rests on evidence I trust?” — then filter.
Filtering model to "Unknown" or "Mixed" works. Neens normalizes the model dimension
the same way for the chart and for the filter, so a filter selects exactly the rows the bar is
drawn from — clicking through a bucket round-trips. Use the labels as shown (Unknown, Mixed),
not the stored values: the raw __mixed__ sentinel never survives normalization and matches
nothing.
Filter model_source when you want to narrow by how the attribution was established
(preprod_run / span / session_uniform / mixed / unknown) rather than by which model ran.
The widget builder’s Custom tab groups by one dimension and does not author filters, so a
filtered tile is created through the API — the same call the gallery makes, with a filters object:
curl -X POST https://your-neens-host/api/dashboards/dsh_your_dashboard/widgets \
-H "Authorization: Bearer nk_sess_your_session_token" \
-H "Content-Type: application/json" \
-d '{
"type": "bar",
"measure": "eval_pass_rate",
"dimensions": ["model"],
"filters": {
"model_source": ["preprod_run", "span", "session_uniform"],
"metric_key": "primary_score",
"score_source": "llm_judge"
},
"range": "30d"
}'That widget reads: pass rate on the Primary Score metric, per model, over the last 30 days,
counting only scores whose model attribution is a single known model. Filter values are always
bound as parameters, and an invalid measure × dimension combination is rejected with 422 — a
widget can never query outside the catalogue.
Fetch its rows with GET /dashboards/{id}/widgets/{wid}/data:
{
"rows": [
{ "model": "claude-haiku-4-5", "eval_pass_rate": 0.94 },
{ "model": "claude-sonnet-4-5", "eval_pass_rate": 0.96 }
]
}Filter values for Model attribution
model_source accepts a single value or a list. The values are the stored vocabulary strings —
preprod_run, span, session_uniform, mixed, unknown — not the wording the chart shows
(Frozen on the run, From the scored span, Uniform across the trace, Mixed,
Unknown). The Mixed bucket can also be selected on the model dimension itself, using the
label Mixed — not the raw __mixed__ sentinel, which is normalized away before the filter is
applied and therefore matches nothing.
Worked example: agent × model
The per-agent half of the comparison is a cross-tab — agents down the side, models across the top — so you can see that a cheaper model is fine for one agent and not for another. Any widget with two dimensions renders this way, whichever visualization it asks for: a bar chart keyed on the agent alone would show the same agent several times with conflicting values.
Add the Pass rate by agent × model preset from the Quality tab, or build it from the API:
curl -X POST https://your-neens-host/api/dashboards/dsh_your_dashboard/widgets \
-H "Authorization: Bearer nk_sess_your_session_token" \
-H "Content-Type: application/json" \
-d '{
"type": "table",
"measure": "eval_pass_rate",
"dimensions": ["agent", "model"],
"filters": { "metric_key": "primary_score" },
"range": "30d"
}'The rows come back one per pair:
{
"rows": [
{ "agent": "support-triage", "model": "claude-haiku-4-5", "eval_pass_rate": 0.95 },
{ "agent": "support-triage", "model": "claude-sonnet-4-5", "eval_pass_rate": 0.96 },
{ "agent": "refund-resolver", "model": "claude-haiku-4-5", "eval_pass_rate": 0.71 },
{ "agent": "refund-resolver", "model": "claude-sonnet-4-5", "eval_pass_rate": 0.93 },
{ "agent": "refund-resolver", "model": "Mixed", "eval_pass_rate": 0.80 }
]
}…and render as one cell per pair:
| claude-haiku-4-5 | claude-sonnet-4-5 | Mixed | |
|---|---|---|---|
| support-triage | 95% | 96% | — |
| refund-resolver | 71% | 93% | 80% |
Read that as a decision, not a score: support-triage loses a point of pass rate on the cheaper
model and can move; refund-resolver loses twenty-two and cannot. That is the rollout plan the
aggregate “94% vs 96%” would have hidden — and it is the reason agent and model are recorded on the
same row rather than left on two different tables.
An empty cell renders as an em dash: that pair has no scores in the window — it is not a zero, and it is not a failure. An agent value of Unknown means the score’s target has no resolvable trace (so no agent name), the same explicit bucket the model side uses.
Pre-prod runs record the model they ran on
A pre-prod evaluation is the cleanest model comparison available, because you control both sides: replay the same golden dataset against the same code on two connections and the only difference is the model.
When a run is created, Neens copies the model and provider off the run’s agent connection and
freezes them on the run. The run header carries a Model badge showing it — or an explicit
Model not recorded when there is nothing honest to show. The run detail
(GET /preprod-evals/{id}) carries the same values:
{
"id": "ppr_…",
"versionLabel": "pr-482",
"runnerMode": "push",
"model": "claude-haiku-4-5",
"modelProvider": "agent_http",
"status": "completed"
}Two honest details:
modelisnullfor a run with no declared model — most commonly a run you drive yourself, where your own harness serves the replay and Neens genuinely does not know what served it. Those scores fall back to the captured trace’s own spans, and are Unknown if the trace recorded no model either.modelProvideris the connection’s provider, i.e. the transport Neens used. When Neens calls your agent at an HTTP endpoint that isagent_http, not an LLM vendor — the vendor is not knowable from an HTTP endpoint, and recording what was actually used beats recording a guess.
Why Neens refuses to pick a dominant model
A trace that used two models has no single correct model, and the tempting fix — attribute it to whichever model produced the most tokens — is the bug this feature exists to prevent. Under token-weighted attribution, a supervisor on an expensive model coordinating workers on a cheap one becomes “a cheap-model trace”, and the cheap model inherits a pass rate it did not earn. That number then survives every review, because nothing about it looks wrong.
So the rules are fixed:
- Two or more models is its own bucket. Mixed is a real, chartable value, not a rounding step.
- Unknown is visible, never blank. A score with no derivable model says Unknown and is excluded from a model comparison rather than quietly merged into one — which would inflate whichever model it landed next to.
- Only model-bearing spans vote. A tool span is not evidence of a model.
- Nothing is re-derived at read time. The attribution and the evidence behind it are recorded together, so two queries over the same window can never disagree about which model produced what.
This is the same posture Neens takes with unpriced models: a visibly missing number beats a confidently wrong one, because you can act on the first and you will plan against the second.
Do not quote a pass rate computed over unknown rows. It is not a claim about a model — it is a
claim about a set of scores whose model you don’t know. Filter attribution first, then quote.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Every bar is Unknown | The scores predate model attribution, or the traces never recorded a model. | Check the Scores by model attribution chart. Scores predating attribution stay unknown and are never guessed at; if new scores are still unknown, fix the model attribute on your LLM spans. |
| A large Mixed bucket | The graded traces genuinely used more than one model — often a fallback or a supervisor/worker split. | Expected for multi-model pipelines. If not expected, open a trace’s Agent Map: LLM nodes are keyed by model, so the second model is visible as a second node. |
| Model isn’t offered in the widget builder | The measure isn’t score-grain or span-sourced. model applies to eval_pass_rate / avg_score / score_count and to spend_usd / tokens_in / tokens_out. | Pick one of those measures — the builder only ever offers valid combinations. |
| Pass rates look higher than the Scores page | The window includes preprod scores, or several metrics with different thresholds. | Filter score_source and metric_key — see the warning above. |
| A model comparison flipped after somebody edited a connection | It shouldn’t — a pre-prod run’s model is frozen at create time. If a production comparison moved, the traces themselves changed models. | Slice by Time to find when the model changed, and cross-check with What changed. |
Filtering model to __mixed__ returns nothing | __mixed__ is the stored sentinel, and Neens normalizes it to the label Mixed before the filter is applied. | Filter model to Mixed (or model_source to mixed). The labels Unknown and Mixed are what the filter accepts, and they match exactly the rows their bars are drawn from. |
Related
- Cost & model pricing — what each model costs, and why an unpriced model is reported rather than guessed at. The other half of “cheaper and still passing”.
- Metrics catalogue — every measure and dimension, including the score-grain slices used here.
- Dashboards — building and sharing the boards these widgets live on.
- Scores — where scores come from and what a threshold means.
- Pre-prod evaluations — the controlled comparison: same dataset, same code, one model changed.
- Sweep decisions — the same question asked of several candidate models at once, answered with a verdict per agent instead of a chart to interpret.
- Agent Map — LLM nodes keyed by model, for seeing a mixed trace step by step.