Compare — Candidate vs Baseline Over a Suite
Definition of done: two experiments on the same suite — a baseline and a candidate — read back item by item, with the per-metric deltas, the cases that went pass → fail (regressions) and fail → pass (fixes), the worst rows, a note on whether the runs are comparable, and the Opik compare-view link. If only one run exists, the done state is "baseline created — rerun after the change". If the suite can't be run or read, stop at the first genuine blocker and return exactly one next step. Aggregate scores alone are not a comparison; a verdict is not this skill's job.
Operate: run the candidate the same way the baseline was run, read both back from Opik rather than from the run's console output, name the specific cases that changed — and change no application code. The only file this skill writes is a throwaway runner outside the repo.
Inputs
The entry point is /opik-compare <suite> (run the suite now as the candidate, compare against the latest prior run), /opik-compare <experiment-A> <experiment-B> (read two existing runs, no new run), or /opik-compare right after /opik-test (that suite). Infer the rest; treat these as optional overrides:
- suite (default:
<project>-regressions, or the one/opik-testjust wrote to) · baseline (default: the most recent earlier experiment on the suite) · candidate name (default:candidate-<short git sha>) · judge model for assertions (default: the suite's / the baseline's) · runs per item (default: the suite's policy).
Ask only at a genuine, non-inferable blocker (see Blockers).
Activation — the only in-scope work
1. Resolve the suite and the baseline
import opik
client = opik.Opik()
suite = client.get_test_suite(name="<suite>", project_name="<project>")
prior = client.get_test_suite_experiments(name="<suite>", project_name="<project>") # newest first is not guaranteed — sort by created_at yourself
Baseline = the most recent prior experiment on this suite, unless the user names one. No prior experiment → this run is the baseline (step 3 still runs; status baseline_created). Two explicit experiments → skip step 3, go to step 4.
Confirm Opik is reachable: if ~/.opik.config exists or OPIK_API_KEY is set, use it. Otherwise → Blocker.
2. Build the task adapter (code candidates)
The suite's items say what to call: each item description written by /opik-test ends in Entrypoint: <root span name>. Grep the repo for that function, import it, and wrap it:
def task(item: dict) -> dict:
return {"input": item["input"], "output": str(entrypoint(item["input"]))}
Write the runner as a temp file outside the repo (or a scratch path the user names) — never into the codebase, never committed. It needs the app's provider credentials; if they're absent → Blocker (the app can't answer the items). Never point the runner at a production entrypoint that writes, sends, or spends.
Prompt candidates (the change is a prompt version, not code): there is no adapter — run server-side instead:
client.rest_client.experiments.execute_experiment(
dataset_name=suite.name, dataset_id=suite.id,
prompts=[{"model": "<model>", "messages": [...], "configs": {}, "prompt_versions": [{"id": "<prompt_version_id>"}]}],
project_name="<project>",
) # 202 Accepted; one experiment per prompt variant, processed asynchronously — poll get_experiment_by_id until items fill
3. Run the candidate
Same suite, same version, same judge model, same runs-per-item as the baseline — vary only the thing under test.
result = opik.run_tests(
test_suite=suite, # or suite.get_version_view("<baseline's version>") to pin
task=task,
experiment_name="candidate-<sha>",
experiment_tags=["compare", "<sha>"],
model="<same judge model as baseline>",
generate_report=False, # default True writes opik_test_suite_reports/ into cwd — the repo stays untouched
)
candidate_id = result.experiment_id # result.experiment_url is the single-run link
# GUARD: a missing judge credential does NOT raise — every item comes back failed with
# scoring_failed=True and a "Missing credentials" reason, and the experiment is still created.
# Treat that as a Blocker, not as a regression; do not compare against that run.
judge_failed = [
r for ir in result.item_results.values() for t in ir.test_results
for r in t.score_results if getattr(r, "scoring_failed", False)
]
Do not read scores off result and stop — step 4 reads both runs from Opik so baseline and candidate go through the same path.
4. Read both runs back (SDK-first, MCP when connected)
base = client.get_experiment_by_id("<baseline_id>")
cand = client.get_experiment_by_id(candidate_id)
b_items = {i.dataset_item_id: i for i in base.get_items()}
c_items = {i.dataset_item_id: i for i in cand.get_items()}
# each item: dataset_item_data, evaluation_task_output, feedback_scores [{name, value, reason}],
# assertion_results [{value, passed, reason}], trace_id
# Side by side, one call, both experiments:
rows = client.get_experiments_client().find_experiment_items_for_dataset(
suite.name, experiment_ids=["<baseline_id>", candidate_id], project_name="<project>"
)
get_experiment_by_name is deprecated — resolve names with get_experiments_by_name and pick by id. When the hosted MCP is connected, list('experiment', name=…) already renders each experiment's per-metric averages and read('experiment', id) gives one run's summary — use them to find and sanity-check the two runs; the item-level join above stays on the SDK.
5. Answer the questions, in this order
- Comparable? Same suite version (
dataset_version/ item count), same judge model, same runs-per-item. If not, say so first — the deltas below are then indicative, not measured. - Which cases broke —
passedin baseline, not in candidate. Each with its input (one line), the failing assertion, itsreason, and the candidate trace link. - Which cases got fixed — the reverse.
- Net effect per metric — mean of each
feedback_scoresname in both runs, and the pass rate; reportbaseline → candidate (delta). - Worst rows — lowest candidate scores; are they the same rows as the baseline's worst?
- App or judge? A flip whose
reasoncites the output is the app; a flip on an unchanged output with a waveringreasonis the judge — flag it, and suggestruns_per_item: 3for that item rather than trusting one run. - Cost vs quality —
total_estimated_cost/ duration on the candidate traces vs baseline, when the traces carry them. - Flaky cases — items that flip across repeated runs of the same code (the suite's execution policy exposes
runs_passed/runs_total). - Trend — when more than two runs exist, the pass rate across the last few, so a one-step delta has context.
Report what the numbers say. Do not decide ship or hold — that is /opik-verify, which applies an explicit release policy to these numbers.
6. Link and hand off
Build the compare link with both ids — a URL-encoded JSON array — so the user lands on the side-by-side view:
import json, urllib.parse
ids = urllib.parse.quote(json.dumps(["<baseline_id>", candidate_id]))
compare_url = f"{ui_base}/{workspace}/experiments/{suite.id}/compare?experiments={ids}"
ui_base is the Opik UI origin (the configured URL minus /api; result.experiment_url shows the exact host and workspace to reuse). Then one next step (see Output) — when the user's question is whether to ship, that step is /opik-verify.
7. Record (only when asked)
If the user wants the finding kept beside the data: a comment on a regressed case's trace via client.rest_client.traces.add_trace_comment(trace_id, text=…), or a human score beside the judge's via client.log_traces_feedback_scores([...]). Never by default.
Blockers
Stop at the earliest blocker and return exactly one next step:
- "Run
opik configure, then rerun/opik-compare <suite>." - "No test suite named
<suite>in project<name>— run/opik-test <trace-id>to create one, or name the suite." - "The suite's items don't say which function to call — pass the entrypoint (
/opik-compare <suite> --entrypoint answer) and I'll build the runner." - "The runner needs a provider credential — set
OPENAI_API_KEY(or the relevant key) and rerun." - "The assertion judge has no credential (every item reports
scoring_failed, 'Missing credentials') — set the judge's provider key and rerun; the run<name>is not a comparable candidate." - "Experiment
<id>has no items yet (server-side run still processing) — rerun in a minute."
Output
User-facing: a short human message — comparability note (if any), regressions first (case, assertion, why), then fixes, then the per-metric table (baseline → candidate (delta)), the compare link, and the single next step. No verdict. Not a raw dump of every item.
Underneath (for composition / evals), one shape:
status:compared|baseline_created|blockedsuite:name,id,versionbaseline/candidate:experiment_id,name,url,items,pass_ratecomparable:true|false,notedeltas: list of{metric, baseline, candidate, delta}(pass rate included)regressions: list of{dataset_item_id, input, assertion, reason, trace_url}fixes: same shapeattribution:{app: [...ids], judge: [...ids]}when a flip could be attributedcompare_urlnext_step: exactly oneverdict: never present
Invariants: compared carries both experiments, a non-empty deltas, and a compare_url with both ids; baseline_created carries baseline only and a next_step of "rerun after the change"; blocked carries exactly one next_step; no path writes into the codebase; no path emits a ship/hold decision.
Examples
Fix verified. /opik-compare support-bot-regressions after fixing retrieve(). Baseline = yesterday's run (3/5 passed). Runner built from Entrypoint: answer; candidate 5/5. Read back: 2 fixes (the refund and shipping items), 0 regressions, pass_rate 0.60 → 1.00 (+0.40), same suite version, same judge. → compared; next step = "run /opik-online-eval to watch the refund assertion in production" or "merge — the decision is yours".
Regression surfaced. Candidate fixes the refund case but the "does not give legal advice" item flips to fail; its reason quotes the new output offering legal steps. Attribution: app. → compared with one regression named first, the delta table after; next step = "/opik-explain <candidate trace> for the legal-advice item".
First run. No prior experiments on the suite. Run once; report 3/5 with the failing items. → baseline_created: "This is the baseline — rerun /opik-compare after your change."
Two existing runs, no code. /opik-compare nightly-2026-09-14 nightly-2026-09-15. Resolve by get_experiments_by_name, join items, report. → compared (no runner built).
Anti-patterns
Reading scores from the run's console output instead of from Opik; comparing runs on different suite versions or judge models without saying so; reporting only aggregates ("accuracy 0.82 → 0.85") without naming the cases that flipped; writing the runner into the repo or committing it; changing app code to make the suite pass; trusting a single run on a flaky item; issuing a ship/no-ship verdict (out of scope by design); using deprecated get_experiment_by_name; building the compare link with one id.
References
Test-suite, experiment, and metric detail live in the opik skill, installed beside this one. Read the files directly — paths are relative to this file: ../opik/references/evaluation-test-suites.md (run_tests, TestSuiteResult, execution policies, versions, get_test_suite_experiments), ../opik/references/evaluation-datasets.md (evaluate(), experiments, OQL), ../opik/references/production.md (cost and latency fields on traces). If your host lays skills out differently, locate the opik skill's references/ directory.
If the opik skill isn't installed, say so in the report and use https://www.comet.com/docs/opik/ rather than working from memory.