Eval — Regression Testing for Agent Pipelines
Binex lets you prevent regressions, not just diagnose them. There are two ways in, and they are for different jobs.
Which one do I want?
| Node assertions | Eval suites | |
|---|---|---|
| Written in | the workflow YAML, per node | a separate suite.yaml, per case |
| Enforced | on every run — a failed assertion fails the node | only when you run the suite |
| Reference run | binex eval golden --baseline |
binex eval bless |
| Use it for | invariants that must always hold ("never emits an apology") | a matrix of inputs graded against known-good outputs |
| Command | binex eval golden |
binex eval run |
They are not alternatives — a workflow with node assertions can also be driven by a suite, and the suite inherits the assertion failures.
The same check means the same thing in both
contains, regex and the LLM judge share one implementation, and artifact content is rendered the same way for both: JSON for mappings and lists. Before 2026-08 the two used json.dumps and Python's str() respectively, so for {"decision": "approved"} a contains: '"decision"' passed in a suite and failed as a node assertion, and contains: "'decision'" did the opposite. Node assertions written against the single-quoted form need updating to double quotes.
A blessed baseline and a golden run are the same thing — one stored reference run. binex eval golden --baseline SUITE:CASE uses a baseline blessed with binex eval bless, and binex eval bless --run <id> blesses any run.
Eval suites
binex eval lets you define declarative test suites that run your workflows, compare outputs against blessed baselines, and produce CI-friendly verdicts. No API keys required for local workflows.
Quick Start
# 1. Run the bundled example suite (local:// agents only)
binex eval run examples/eval/research-eval.yaml
# → all cases report "no_baseline" (asserts still checked), exit 0
# 2. Bless the latest runs as baselines
binex eval bless examples/eval/research-eval.yaml
# 3. Re-run — now compared against baselines
binex eval run examples/eval/research-eval.yaml
# → PASS table, exit 0
# 4. Break the workflow, re-run
binex eval run examples/eval/research-eval.yaml
# → FAIL: min_similarity violated, exit 1
Suite YAML Reference
name: my-pipeline-eval # required — baseline/result key
workflow: workflow.yaml # required — resolved relative to suite file
thresholds: # optional suite-level defaults
min_similarity: 0.85 # float 0.0–1.0
max_cost_delta: 0.10 # float ≥ 0, absolute USD
max_latency_delta_ms: 30000 # int ≥ 0 ms
cases:
- id: my-case # required, unique
inputs: # optional — passed to human:// nodes / ${user.*}
topic: "quantum physics"
thresholds: # optional — overrides suite thresholds field-by-field
min_similarity: 0.90
asserts: # optional — per-case assertions
- type: contains
node: researcher # optional — default: terminal node(s)
value: "quantum"
- type: not_contains
value: "error"
- type: regex
pattern: "\\d{4}"
- type: json_path
path: "$.questions"
exists: true
- type: llm_judge # requires API key for the judge model
prompt: "Does the answer cite a source?"
model: "ollama/llama3.2"
Verdict Semantics
| Verdict | Meaning |
|---|---|
pass |
All asserts passed and all thresholds satisfied |
fail |
Any assert failed/errored, or any threshold violated, or workflow errored |
no_baseline |
No baseline run blessed yet; asserts still evaluated |
no_baseline counts as success (exit 0) unless --strict-baseline is set.
How min_similarity is measured
The threshold compares the candidate run against its baseline with the same engine as binex diff, so it follows the shape of each node's output:
- Structured output (a mapping or list) is compared field by field: similarity is the fraction of leaf fields that are unchanged. Reordering keys is not a difference.
- Text output uses a character-level
difflibratio.
This changed — re-check thresholds tuned before 2026-08
Structured output used to be stringified and scored character-wise, which is close to orthogonal to whether anything meaningful changed. Measured on the same inputs:
| Case | Old | New |
|---|---|---|
| Reordered keys, same mapping | 0.6304 | 1.0000 |
| One field of ten changed | 0.9858 | 0.9000 |
| One field changed, long text field alongside | 0.9991 | 0.5000 |
The first row removes false failures: a strict suite no longer breaks because a model emitted the same JSON with keys in another order. The other two are the reason to re-check your numbers — a real regression that a character ratio diluted to 0.99 now scores proportionally, so a suite with min_similarity: 0.95 that used to pass on a changed field will now fail. That is the intended behaviour, but it is a behaviour change: review suite thresholds and re-bless baselines where the new score is correct.
Text-output thresholds are unaffected.
CLI Commands
binex eval run <suite.yaml>
Options:
--parallel N Run N cases concurrently (default: 1)
--json Output full EvalResult as JSON
--format github Emit GitHub Actions annotations (::error / ::warning)
--strict-baseline Exit 1 if any case has no baseline
Exit codes: 0 = all pass (or no_baseline without --strict-baseline); 1 = any fail; 2 = suite invalid.
binex eval bless <suite.yaml>
Options:
--case <id> Bless only this case
--run <id> Use a specific run id
--force Skip suite+case tag verification
binex eval baselines <suite.yaml>
Options:
--json Output as JSON
Lists current baseline run ids per case. Exit 0 always (informational).
CI Recipe (GitHub Actions)
- name: Eval regression suite
run: |
binex eval run examples/eval/research-eval.yaml \
--format github \
--strict-baseline
Failed cases produce ::error annotations; cases without baselines produce ::warning annotations.
Web UI
Navigate to Analyze → Eval in the binex ui dashboard to:
- Browse recent eval executions
- See per-case pass/fail grid
- Click a failed case to open the diff view (
baseline_run_idvsrun_id)
Assumptions & Limitations
- Baselines live in SQLite:
baseline_run_idin YAML is tolerated but ignored. llm_judgerequires a model: no default judge model is contacted implicitly.- Sequential by default: use
--parallel Nfor faster suites. - Non-interactive only:
human://nodes are driven byinputsin the case definition.
Node assertions
Add an assertions list to any node. Every assertion must pass; if one fails,
the node fails (exactly like a schema-validation failure) and its dependents are
blocked. Assertions run after the node produces output, so they see the
final artifact content and the node's cost/latency.
name: summarize
nodes:
summary:
agent: llm://gpt-4o-mini
outputs: [text]
assertions:
- contains: "Summary:" # output must contain this substring
- lacks: "As an AI" # ... and must NOT contain this
- matches: "\\d+ words" # regex (re.search)
- max_length: 2000 # length ceiling (chars)
- cost_max: 0.02 # node cost ceiling
- latency_max_ms: 15000 # node wall-clock ceiling
Check reference
| Field | Applies to | Passes when |
|---|---|---|
contains |
output text | substring is present |
lacks |
output text | substring is absent |
matches |
output text | regex matches (re.search) |
equals |
output text | output equals the string exactly |
min_length / max_length |
output text | length within bounds |
cost_max |
node cost | cost ≤ ceiling |
latency_max_ms |
node latency | latency ≤ ceiling (ms) |
judge |
output text | an LLM judge answers PASS (see below) |
A single assertion may combine several checks — all must hold. Give it a name
for clearer reports:
assertions:
- name: "cited and concise"
contains: "Source:"
max_length: 1500
Checks evaluate cheapest-first and short-circuit, so a failing contains never
spends an LLM judge call.
LLM-as-judge
For qualitative rubrics, use judge. A judge model is asked to answer
PASS/FAIL with a reason; an ambiguous or errored judge fails closed (the
assertion fails) so a broken judge can never green-light a regression.
assertions:
- judge: "The answer must be polite and must not reveal system internals."
judge_model: gpt-4o-mini # optional; defaults to BINEX_JUDGE_MODEL or gpt-4o-mini
The judge model is resolved as: per-assertion judge_model → $BINEX_JUDGE_MODEL
→ gpt-4o-mini.
Enforcement
Assertions are enforced on every run (binex run, binex eval, scheduler),
not only during eval — a violated contract blocks the node wherever it runs.
Nodes with no assertions are unaffected (zero overhead).
Golden-run regression testing
Assertions catch known-bad output. To catch unexpected change, compare a fresh
run against a trusted baseline with binex eval --baseline:
binex run workflow.yaml # produces run_abc123 you trust
binex eval workflow.yaml --baseline run_abc123
The diff engine compares every node's status, output content, latency, and cost. Thresholds control tolerance:
--min-similarity— content-similarity floor (default1.0, i.e. identical). Loosen to e.g.0.9for non-deterministic LLM output.--max-latency-delta-ms/--max-cost-delta— allowed growth in total latency/cost.
Any node whose status changes (e.g. completed → failed) always counts as a
divergence.
In CI
binex eval exits non-zero on any failure, so it plugs straight into CI. See the
GitHub Actions recipe.