Pre-prod evaluations
A pre-prod evaluation runs a candidate version of your agent against a frozen golden dataset before it ships, scores the results with the same judges you run in production, and compares them to a baseline — a previous candidate or your live production quality. Items that passed the baseline but fail the candidate are regressions, and the run’s gate turns them into a pass/fail verdict your CI can block on.
Where eval gates guard a single known failure, a pre-prod eval sweeps a whole candidate version across your curated set — so a quality drop is caught before users see it, not after.
At a glance
| Where | The Pre-prod Evals page (comparison view under Compare Versions) |
| Key API routes | POST /preprod-evals, GET /preprod-evals/{id}, POST /preprod-evals/{id}/run, GET /preprod-evals/{id}/comparison, GET /preprod-evals/{id}/metrics, GET /preprod-evals/{id}/gate |
| Auth | The same nk_live_ agent API key you use to send traces authenticates the whole flow — no separate login. |
| Needs | A dataset with a golden version (Datasets); enabled judges (Judges); to let Neens call your agent, an Agent endpoint connection |
| Scope | Agent-scoped, like all your data; runs execute on a dedicated worker fleet so a big sweep never slows live scoring |
| Produces | Per-item scores tagged source: preprod (their own score type — filterable out of production widgets by Score source) and attributed to the model frozen on the run, a candidate-vs-baseline comparison, a gate verdict, and a Pre-prod regression insight when something regressed |
Two ways to run
Both ways produce the same run, comparison, and gate — they differ only in who invokes your agent:
- Neens calls my agent. You register your agent’s HTTP endpoint once; Neens calls it with every golden prompt, records each response as a session, and scores it. No harness, no exporter wiring — the lowest-effort path.
- I run my agent. You drive your agent over the golden prompts yourself — by hand or wired
into your CI pipeline — and the traces it emits carry correlation attributes that link them
back to the run. Use this when your agent already runs in a harness, or isn’t reachable from
Neens. The
neens eval runCLI does the busywork.
Authentication — one key, set once
Every pre-prod route accepts the nk_live_ agent API key you already mint to send traces
(Settings → API keys), sent as Authorization: Bearer <key>. It resolves to a company +
agent on its own, so:
- No
nk_sess_session/login token. - No
--project-id— the key already scopes the run to an agent (pass one only to override).
For both the CLI and CI, set two environment variables and you’re done:
export NEENS_BASE_URL="https://neens.example.com" # your Neens origin
export NEENS_API_KEY="nk_live_..." # your agent (ingest) API keyThe same key drives create, list, start, run, cancel, items, gate, and the comparison
endpoints. In CI, store it once as a secret (e.g. NEENS_API_KEY) and reuse it for every run.
Prerequisites
- A golden dataset. A dataset version marked golden is the reference set — each item carries the frozen prompt (and optional expected answer) the candidate is asked to run. Build one from human-labeled failures or a filter — see Datasets and Annotations & review.
- Judges. A run defaults to your agent’s Primary Score — the overall quality gate — and you can pick any other enabled judge(s) to score the replay. LLM-prompt, managed, and composite judges (like Primary Score) all participate. A classifier judge only assigns a category label rather than a pass/fail score, so it isn’t offered here.
- If you run your agent: an agent that exports OpenTelemetry traces to Neens (Send traces). If Neens calls your agent: an HTTP endpoint Neens can reach.
Create a run
In Pre-prod Evals → New pre-prod eval, everything except the version label has a smart default:
| Field | Purpose |
|---|---|
| Run name | A human name for the run (e.g. Nightly regression check). |
| Version label | Free-form label for the candidate agent version (a git SHA, a branch, v2.1). Recorded on every captured trace so candidates stay distinguishable. |
| How does the run happen? | Neens calls my agent or I run my agent. |
| Agent endpoint | Only when Neens calls your agent — the registered Agent endpoint connection it calls. |
| Golden dataset | The dataset whose frozen prompts get replayed. |
| Version | Auto — golden uses the dataset’s golden version; pin a specific version to replay an older frozen snapshot. |
| Baseline | Compare to production (a production time window, default last 7 days) or Compare to a previous run. |
| Max regressions | Gate threshold: fail when more than this many items regress. Blank = not enforced. |
| Min pass rate (%) | Gate threshold: fail when the candidate’s pass rate falls below this floor. Blank = not enforced. |
| Judges | Which judges score the replay. Defaults to Primary Score; pick any enabled scoreable judge(s). |
The API accepts an explicit judge_deployment_ids list; omit it to default to Primary Score.
Only scoreable judge types (LLM-prompt, managed, composite) are accepted — a classifier-only
selection is rejected.
Version label ≠ dataset version. The version label names your candidate agent; the dataset version selects the golden prompts it runs against. They are independent knobs.
Creating the run snapshots the chosen version’s items immediately, so the exact prompt set is frozen for the run even if the dataset is edited later. A dataset with no golden version is rejected unless you pin a version explicitly.
Via the API:
curl -sf -X POST "$NEENS_BASE_URL/preprod-evals" \
-H "Authorization: Bearer $NEENS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "release-candidate",
"dataset_id": "ds_golden",
"version_label": "'"$GIT_SHA"'",
"baseline": {"kind": "prod_window", "range": "7d"},
"gate": {"max_regressions": 0, "min_pass_rate": 0.9}
}'Optional fields: dataset_version_id (pin a snapshot), runner_mode ("runner" = you run the
agent, the default, or "push" = Neens calls your agent, with agent_connection_id),
judge_deployment_ids, and baseline: {"kind": "preprod_run", "value": "<prior run id>"}.
The gate object can also carry a cost budget — see
the server-side cost gate.
The candidate’s model is frozen at create time
When Neens calls your agent, the run’s Agent endpoint connection declares a model. Neens copies that model (and the connection’s provider) onto the run when the run is created, and never re-reads it — so “Haiku passed at 94% on the June release” keeps meaning the same thing after somebody edits the connection in July. Every score the run produces is attributed to that frozen model, which is what makes a pre-prod run the cleanest model comparison available: same golden prompts, same code, one model changed. The run header shows it as a Model badge — or an explicit Model not recorded rather than a blank that would read as a model name.
The run detail carries the same values:
{
"id": "ppr_…",
"versionLabel": "pr-482",
"runnerMode": "push",
"model": "claude-haiku-4-5",
"modelProvider": "agent_http"
}model is null when there is nothing honest to record — most often a run you drive yourself,
where Neens does not know what served the replay, or a connection that declares no model. Those
scores fall back to the model recorded on the captured trace’s own spans, and are reported as
Unknown if the trace recorded none. Nothing is guessed at. modelProvider is the connection’s
provider — for an agent endpoint that is agent_http, the transport Neens dialled, not an LLM
vendor (which an HTTP endpoint does not reveal).
I run my agent: neens eval run
The neens-eval SDK (dependency-free) drives the whole flow for you: it fetches the frozen
prompts, invokes your agent command once per item with the correlation attributes injected (via
OTEL_RESOURCE_ATTRIBUTES), starts the run, polls it to a verdict, and exits with the gate
result. Run it by hand on your laptop or wire it into your CI pipeline — the command is the same.
It ships in two flavors with identical flags and exit-code contract — pick the one that matches your stack:
pip install neens-eval # installs the `neens` console script
# or, from a Neens source checkout: pip install ./sdkPython 3.10+, stdlib only. Invocation is neens eval run ….
Ad-hoc: create and run in one command
With NEENS_BASE_URL and NEENS_API_KEY exported (see Authentication),
--create makes a run against your dataset’s golden version and drives it in a single command:
neens eval run --create \
--dataset ds_golden \
--version-label local-test \
-- python -m myagentEverything after -- is your agent command; it’s invoked once per frozen prompt with the
prompt piped to stdin (and available as $NEENS_ITEM_INPUT). No --project-id, no session
token — the nk_live_ key scopes and authenticates the whole run.
To drive a run you already created, swap --create --dataset … for --run-id <id>.
Flags for neens eval run
| Flag | Purpose |
|---|---|
--run-id ID | Drive an existing run. Mutually exclusive with --create. |
--create | Create a run first (requires --dataset and --version-label). |
--dataset ID | Golden dataset to snapshot (with --create). |
--dataset-version-id ID | Pin an explicit dataset version (defaults to the golden version). |
--version-label LABEL | Candidate version label; also tags every emitted trace. |
--name NAME | Run name (defaults to the version label). |
--baseline SPEC | prod, prod:<range> (e.g. prod:30d), or run:<preprod_run_id>. |
--max-regressions N | Gate: fail if regressions exceed N. |
--min-pass-rate R | Gate: fail if the pass rate is below R (0..1). |
--gate-policy PATH | A gate-as-code policy file; env NEENS_GATE_POLICY. The Python SDK reads JSON (or YAML with PyYAML); the TypeScript SDK reads JSON only. |
--max-cost-delta-pct PCT | Inject a block rule: fail if candidate avg cost rises more than PCT% vs baseline. |
--max-latency-delta-pct PCT | Inject a block rule: fail if candidate avg latency rises more than PCT% vs baseline. |
--min-avg-score R | Inject a block rule: fail if the run’s aggregate avg score is below R (0..1). |
--max-abs-failures N | Inject a block rule: fail if absolute candidate failures exceed N. |
--base-url URL | Neens origin (env NEENS_BASE_URL). |
--api-key KEY | nk_live_… agent key (env NEENS_API_KEY). |
--project-id ID | Optional X-Neens-Project-Id override (env NEENS_PROJECT_ID; usually unneeded). |
--timeout SECONDS | Per-item command timeout. |
--concurrency N | Parallel item invocations (default 1). |
--poll-interval SECONDS | Status poll cadence (default 5). |
--poll-timeout SECONDS | Overall scoring budget (default 1800). |
--no-gate | Report the gate but always exit 0 (don’t fail CI). |
--json | Emit a machine-readable result to stdout (human logs go to stderr). |
In CI: a GitHub Actions gate
Store your Neens origin and agent key as repo secrets, then add one step. The exit code is
the gate verdict, so the step fails the build automatically when the candidate regresses. Using
${{ github.sha }} as the version label ties the run to the commit under test:
- name: Pre-prod eval gate
env:
NEENS_BASE_URL: ${{ secrets.NEENS_BASE_URL }}
NEENS_API_KEY: ${{ secrets.NEENS_API_KEY }} # a nk_live_… agent key
# Point YOUR agent's OTel exporter at the Neens receiver:
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${{ secrets.NEENS_BASE_URL }}/v1/traces
run: |
neens eval run --create \
--dataset ds_golden \
--version-label "${{ github.sha }}" \
--baseline prod:7d \
--min-pass-rate 0.9 --max-regressions 0 \
-- python -m myagentBecause --create self-creates the run each time, the only sticky identifiers in CI are the
dataset id and the NEENS_API_KEY secret — there’s no run id to copy between builds.
The exit-code gate contract
The process exit code is the gate verdict, so a plain CI step blocks a bad release:
| Exit code | Meaning |
|---|---|
0 | Gate passed (or --no-gate was set) |
1 | Gate failed — regressions or pass rate breached a threshold |
2 | Run error — no items, backend error, poll timeout, or bad arguments |
--no-gate still fetches and prints the gate but always exits 0 (report without blocking CI).
--json emits a machine-readable result to stdout (human logs go to stderr) for a step to
parse.
Gate-as-code: a richer policy file
--min-pass-rate and --max-regressions are the thresholds the server gate owns from the
command line — and the run’s stored gate can carry a cost budget
alongside them. On top of those you can commit a gate policy — a JSON (or YAML, if PyYAML is
installed) file that
declares budgets and floors the CLI evaluates client-side over the comparison, per-metric
rollup, and run-detail data Neens already returns. Point the CLI at it with --gate-policy PATH
(or set NEENS_GATE_POLICY); it works with both neens eval run and neens eval push.
{
"gate": { "max_regressions": 0, "min_pass_rate": 0.9 },
"rules": [
{ "cost": { "max_avg_usd": 0.05, "max_delta_pct": 20 }, "severity": "warn" },
{ "latency": { "max_avg_ms": 3000, "max_delta_pct": 25 }, "severity": "warn" },
{ "steps": { "max_avg": 8, "max_delta_pct": 50 }, "severity": "block" },
{ "max_abs_failures": 3, "severity": "block" },
{ "min_avg_score": 0.8, "severity": "block" },
{ "metric": "faithfulness", "min_avg": 0.8, "severity": "block" },
{ "metric": "toxicity_safety", "min_avg": 0.9, "severity": "warn" }
],
"on_missing_baseline": "warn"
}The gate block is handed to the server at run-create time, so Neens stays authoritative for
max_regressions/min_pass_rate. The rules list is what the CLI evaluates. Each rule carries
exactly one dimension key plus an optional severity:
| Rule | Fails when |
|---|---|
cost | max_avg_usd — candidate avg cost exceeds the budget; max_delta / max_delta_pct — cost rose more than that vs the baseline. Costs come from the model price table; if no session on a side has a priced model the average is null, and the rule is reported as skipped (unpriced) rather than silently passing. |
latency | max_avg_ms; max_delta_ms / max_delta_pct — same, for average latency. |
steps | max_avg; max_delta / max_delta_pct — same, for average step count. |
max_abs_failures | Absolute candidate failures exceed the cap. |
max_regressions | Regressions vs the baseline exceed the cap. |
min_pass_rate | The run’s pass rate falls below the floor. |
min_avg_score | The run’s aggregate average score falls below the floor. |
metric | The named metric’s average score (from the rollup) is below min_avg or above max_avg. Scores are normalized 0–1 (higher is better), so use min_avg even for a safety floor. |
Two severity tiers. severity: "block" (the default) fails the build — a breached block rule
sets the CLI exit code to 1 on top of the server gate. severity: "warn" is reported in the
summary but never changes the exit code.
Missing baseline. on_missing_baseline governs baseline-relative rules (any *_delta /
*_delta_pct budget, and max_regressions) when the run has no baseline cohort to compare
against — warn (the default) reports without blocking, block honors each rule’s own severity,
and pass skips them entirely. Absolute rules (max_avg_*, max_abs_failures, min_pass_rate,
min_avg_score, and metric floors) always evaluate — they need no baseline.
For a quick one-off gate you don’t need a file: four convenience flags inject the equivalent
block rules straight from the command line, overriding any same-dimension rule in the file —
--max-cost-delta-pct, --max-latency-delta-pct, --min-avg-score, and --max-abs-failures.
The metric rule reads GET /preprod-evals/{id}/metrics — a per-metric rollup of the run’s
captured, scored sessions. It returns one row per judge metric with its normalized average score
(avgScore), pass rate (passRate), and the number of sessions scored, letting the gate enforce a
floor the run’s single aggregate score can’t (e.g. faithfulness must stay ≥ 0.8). A run with
nothing scored yet returns an empty list rather than erroring.
The server-side cost gate
A run’s stored gate — the gate object you pass to POST /preprod-evals, and the one a
model sweep pins onto every child run — accepts a cost family
alongside max_regressions and min_pass_rate, evaluated by Neens rather than by the CLI:
{
"max_regressions": 0,
"min_pass_rate": 0.9,
"cost": { "max_avg_usd": 0.004, "max_delta_pct": -50 }
}The three sub-keys are spelled exactly as the SDK’s client-side cost rule spells them, so one
vocabulary covers both sides:
| Sub-key | Fails when |
|---|---|
max_avg_usd | The candidate’s average cost per captured session exceeds this budget |
max_delta | Cost rose more than this many dollars versus the baseline |
max_delta_pct | Cost rose more than this percentage versus the baseline. A negative threshold is a required saving — -50 means “must come in at least 50% cheaper” |
GET /preprod-evals/{run_id}/gate returns a costRules array whenever the gate declares the
family — one row per declared sub-key, each with rule, status, observed, threshold and a
plain-English reason. The key is absent (not an empty array) when there is no cost family, so
every gate written before this existed returns exactly what it always did.
{
"passed": false,
"reasons": ["cost avg 0.0062 USD exceeds budget 0.004 USD"],
"regressions": 0,
"passRate": 0.95,
"gate": { "max_regressions": 0, "min_pass_rate": 0.9, "cost": { "max_avg_usd": 0.004 } },
"costRules": [
{ "rule": "max_avg_usd", "status": "fail", "observed": 0.0062, "threshold": 0.004,
"reason": "cost avg 0.0062 USD exceeds budget 0.004 USD" }
]
}An unpriced run is skipped, never pass. If no session in the run ran on a model with a
price, the average cost is null — and reading null as “$0, therefore under budget” is exactly
the bug per-model pricing exists to kill. The rule comes back status: "skipped" with a reason
naming the unpriced models and pointing at Settings → Model pricing,
and the skip is repeated in reasons. Only a fail blocks. A gate whose conditions all held but
whose cost rule could not be measured says “all evaluated gate conditions satisfied” — a
deliberately weaker sentence than the usual one.
A rule needing a baseline (max_delta, max_delta_pct) is also skipped when the run has no
baseline cohort, rather than being scored against nothing.
Two places, two evaluators — same vocabulary. cost is spelled
{max_avg_usd, max_delta, max_delta_pct} wherever you write it, but where you write it decides
who checks it:
| Where | Evaluated by | When it runs |
|---|---|---|
the policy file’s top-level gate block (or the run’s / sweep’s gate over the API) | Neens, server-side | GET /preprod-evals/{id}/gate, reported under costRules; stored with the run |
the policy file’s rules list | the SDK, client-side | at the end of neens eval run, against the comparison payload; supports severity and on_missing_baseline |
Put it in gate when the budget should hold for everyone who reads the run — including the UI
and anyone re-checking it later. Put it in rules when it is this pipeline’s opinion, or when
you want severity: warn. Writing it in both is fine and simply means both sides check it.
{
"gate": {
"min_pass_rate": 0.9,
"cost": { "max_delta_pct": -50 }
}
}That policy is literally “cheaper and still hits the number” — a candidate must pass at least 90% of items and cost at least 50% less than the baseline — enforced in CI and visible in the product, from one file.
The correlation contract
Neens links each trace your agent emits to a pre-prod item by three attributes, readable from either span attributes or resource attributes (both OpenTelemetry and OpenInference conventions work — resource attributes are flattened onto spans at ingest):
| Attribute | Required | What it does |
|---|---|---|
neens.eval_run_id | Yes | Links the trace to the pre-prod run (the run’s id, e.g. ppr_…). |
neens.dataset_item_id | Strongly recommended | Pins the trace to the exact frozen prompt. Use the item_id returned by GET /preprod-evals/{id}/items. |
neens.version_label | Optional | Records the candidate version on the trace. |
These three keys are a stable wire contract, held fixed across releases by a CI guard, and they
are matched at exactly this spelling — an attribute under any other name is stored as ordinary
trace metadata and the run stays in awaiting_traces with no error.
The neens eval run CLI injects all three for you via OTEL_RESOURCE_ATTRIBUTES — you don’t
touch your agent code. If you drive the run without the CLI, tag them yourself:
The simplest way to tag every span an agent process emits is a resource attribute:
export OTEL_RESOURCE_ATTRIBUTES="neens.eval_run_id=ppr_1234,neens.dataset_item_id=<item_id>,neens.version_label=release-2.1"
python my_agent.py # exporter pointed at the Neens /v1/traces as usualOr set them as span attributes in code:
with tracer.start_as_current_span("agent.run") as span:
span.set_attribute("neens.eval_run_id", "ppr_1234")
span.set_attribute("neens.dataset_item_id", item_id)
span.set_attribute("neens.version_label", "release-2.1")
...GET /preprod-evals/{run_id}/items returns the frozen prompt list to drive your agent with:
{ "items": [ { "item_id": "…", "input": "<golden prompt>", "expected_output": "…" } ] }How linking behaves, exactly:
- With
neens.dataset_item_id: the trace links only to that item. If the id matches no still-pending item (a duplicate replay of an already-captured prompt, or a typo), the trace is not linked to some other prompt — mislinking would corrupt that prompt’s score. - Without it: the trace links to the first still-captured-less item in frozen order. Fine for a strictly sequential harness; tag the item id if you run prompts concurrently or retry.
- A trace whose run id belongs to another agent, or to a cancelled run, is ignored.
The first captured trace moves the run from awaiting_traces to running; once every item
has a captured trace, scoring is dispatched automatically (exactly once, even if the last traces
land simultaneously).
Neens calls my agent
When Neens calls your agent, it HTTP-calls your agent’s endpoint once per golden prompt, records each response as a session, links it to its item, and scores the run — no harness on your side.
Register an agent endpoint
An agent endpoint is a connection whose provider is Agent endpoint (agent_http). Create it
under Settings → Connections like any LLM connection —
its credential is encrypted at rest and it’s visible only to the agents the connection is
scoped to, so a run in one agent can never invoke another agent’s endpoint.
| Field | Purpose |
|---|---|
| Base URL | The origin Neens calls, e.g. https://my-agent.internal. Required. |
| Request shape | openai_chat or input_json (see the contract below). |
| Invoke path | Appended to the base URL. Defaults to /chat/completions for openai_chat, / for input_json. |
| Auth | bearer sends the stored credential as Authorization: Bearer …; none sends no auth header. |
| Output JSONPath | Optional dotted/bracketed path (e.g. data.reply) to pluck the answer when it isn’t at the shape’s default location. |
| Model | openai_chat only — the model id sent in the request body. |
Know the request/response contract
Any OpenAI-compatible chat-completions endpoint works unmodified. Neens POSTs:
{ "model": "your-model", "messages": [{ "role": "user", "content": "<golden prompt>" }] }and reads the answer from choices[0].message.content (falling back to
choices[0].message.reasoning_content for gateways that route answers there, or your
Output JSONPath if set).
Either way, if your endpoint responds with a full OpenTelemetry (resourceSpans) or
OpenInference (spans) trace body instead of a plain answer, Neens parses and records the
real multi-span run faithfully. Otherwise it synthesizes a minimal single-span session from the
prompt and answer.
Create and run
Create the run with Neens calls my agent and your endpoint selected, then click Run now.
Via the API, POST /preprod-evals/{id}/run kicks it off (this trigger only applies when Neens
calls your agent — when you run the agent there’s nothing to trigger; the run captures traces on
ingest). Neens invokes the endpoint per frozen prompt, captures and links each response, then
scores automatically.
Or drive the whole thing from the CLI with neens eval push (create + trigger + gate, same
exit codes), pointing --agent-connection at the registered endpoint:
neens eval push \
--dataset ds_golden --version-label "$GIT_SHA" \
--agent-connection "$AGENT_CONNECTION_ID" \
--min-pass-rate 0.9 --max-regressions 0Guardrails on the outbound call
When Neens calls an endpoint you own, every call is bounded and every failure is honest — Neens never fabricates an output or a score:
- Per-call timeout — 60 s. A slow or hung endpoint errors that one item; the rest of the run continues.
- Prompt truncation — the prompt sent to your endpoint is truncated at 200,000 characters, so a pathological golden item can’t blow up the request body.
- Response cap — 8 MiB; an oversized body errors the item instead of exhausting worker memory.
- Per-item failures stay per-item — a timeout, connection failure, non-2xx status, empty
body, or a reply with no answer at the expected location marks that item
errorwith the reason recorded. If every item fails (e.g. the endpoint was down), the run is markedfailed— never “completed” with nothing scored.
The endpoint must be reachable from the public internet. Neens calls the URL you register
from its own servers, guarded against SSRF: only http/https URLs are allowed, redirects are
never followed, and any host that resolves to a private, loopback, or link-local address (cloud
metadata IPs, localhost, VPC ranges, …) is refused. Register a publicly reachable endpoint
— an agent that only lives on an internal address won’t be callable this way; drive the run
yourself instead (I run my agent) and emit traces to Neens.
How scoring works
When a run’s items are captured (either way of running), the dedicated pre-prod worker scores each captured session with every judge on the run:
- Each judge scores the session and its verdict is persisted as a normal score row — but tagged
source: preprod, so pre-prod results appear in the score catalogue as their own score type (a separate card, never merged into the production one) and on the captured trace. The tag is also what lets any dashboard widget keep them out: slice or filter by Score source (score_source) on the score-grain measures. A widget that doesn’t will count pre-prod scores alongside production ones. - The item’s score is the mean of its judge scores; the item passes when that mean is at or above 0.7.
- An item whose judges all errored is marked
error(with the reason) — never silently scored.
Then the run’s baseline is resolved and each item’s baseline_passed is stamped:
- Previous pre-prod run (
preprod_run) — the prior run’s verdict for the same frozen prompt (both runs snapshot the same golden version, so the prompt id is a stable join key). - Production window (
prod_window) — production scores (any source exceptpreprod) over the window, matched to each golden item by input text: a prod session whose input equals the item’s frozen prompt supplies the verdict (its newest score wins; pass = apasslabel, or score ≥ 0.7). Items whose prompt never ran in production get no baseline verdict — they can’t regress, but they still count toward the pass rate.
A regression is an item where the baseline passed and the candidate failed. Any
regressions put the run in completed_with_regressions and emit a Pre-prod regression
insight — so a blocked release shows up in your feeds (and notification
sinks) with the run attached, not just as a red CI check.
Reviewing a run
Open a run on Pre-prod Evals to see:
- Per-item results — each frozen prompt’s status, score, pass/fail, baseline verdict, and captured trace (click through to the full trace), plus the recorded error for failed items.
- The comparison (
GET /preprod-evals/{id}/comparison) — a per-item pass/fail matrix, the regression set, new passes (items the candidate fixed), and average cost / latency / step-count deltas between the candidate’s captured sessions and the baseline cohort. Each row also carries the candidate’s agent output and the item’s expected output. Average cost covers the priced sessions only; if a side has no priced session the cost delta isnullrather than a comparison against a made-up rate — see Cost & model pricing. - The gate (
GET /preprod-evals/{id}/gate) — pass/fail with reasons.
The gate never reports green before the candidate has actually been evaluated: a run that is
failed or cancelled, still in progress, or finished with zero scored items fails the
gate outright. Only then are the thresholds checked — max regressions and min pass rate.
Comparing multiple versions
Compare Versions lines up N runs that snapshot the same golden version side by side —
one row per prompt, a cell per version, with per-version pass rate, cost, latency, and step
count. Pick any run as the baseline to highlight which versions regressed against it. Via the
API: GET /preprod-compare?runs=ppr_a,ppr_b,ppr_c&baseline=ppr_a (mixing golden versions
returns 422).
Expand a prompt row to read each run’s agent output (the real final answer that version produced) alongside the item’s expected output, so a red cell is “here’s what it actually said” — not just a failed check. A run with nothing captured for that prompt shows no output captured.
For any single prompt, the trajectory diff
(GET /preprod-compare/trajectory?run=…&baseline=…&item=…) aligns the two versions’ step
sequences and marks each step added / removed / changed / same — turning “v3 regressed on
this prompt” into “v3 stopped calling lookup_order.”
Run lifecycle
| Status | Meaning |
|---|---|
awaiting_traces | Created; items snapshotted; waiting for captures (or for Run now when Neens calls your agent). |
running | At least one trace captured (you run the agent), or the endpoint sweep is in flight (Neens calls it). |
scoring | All items captured; judges are scoring. |
completed | Scored, no regressions vs the baseline. Terminal. |
completed_with_regressions | Scored, one or more regressions. Terminal. |
failed | Nothing could be scored (e.g. every endpoint call errored, or scoring crashed). Terminal. |
cancelled | Cancelled via POST /preprod-evals/{id}/cancel. Terminal — an in-flight endpoint sweep stops calling out, and a cancelled run is never resurrected by late traces or scoring. |
Item statuses: pending → captured → scored, or error (invocation or scoring failed —
the reason is recorded on the item).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
403 from neens eval run / the API | The key isn’t a nk_live_ agent key, or it’s scoped to a different agent | Use the agent’s nk_live_ API key (Settings → API keys); set it as NEENS_API_KEY. No session token or --project-id is needed |
Run stuck in awaiting_traces (you run the agent) | Not every item has a matching trace — scoring starts only when the run is fully captured | Check the tagged neens.eval_run_id / neens.dataset_item_id values against GET /preprod-evals/{id}/items; a mistagged item id is deliberately not linked to another prompt |
| An item errors with “url is not allowed” (Neens calls your agent) | The endpoint resolves to a private/loopback address Neens can’t call | Register a publicly reachable endpoint, or drive the run yourself (I run my agent) and emit traces to Neens |
Run marked failed (Neens calls your agent) | Every item’s endpoint call failed (down, timing out, or returning an unreadable body) | Fix the endpoint or its Request shape / Output JSONPath, then create a new run |
Some items have no baseline verdict (prod_window) | Those golden prompts never ran (or were never scored) in production during the window | Expected — widen the window, or baseline against a previous pre-prod run |
| Gate fails with “no items were scored” | Judges couldn’t run — e.g. no enabled LLM-prompt judges, or an unusable LLM connection | Enable at least one LLM-prompt judge and verify its connection, then re-run |
| Pre-prod scores showing up in a production dashboard | Pre-prod scores carry source: preprod, but a score-grain widget counts every source unless you say otherwise | Filter or slice the widget by Score source (score_source) — see Metrics catalogue and Model comparison |
A run’s model is null | The run has no agent connection (you ran the agent yourself), or the connection declares no model | Expected — Neens never invents one. Its scores fall back to the captured trace’s own spans; see Model comparison |