Skip to content

Comparison & gates

compare_experiments pairs two experiments by case id and reports, per case and in aggregate, the change (candidate − baseline) in pass rate, mean score per evaluator, cost, latency and steps. Each aggregate delta gets a paired bootstrap 95% confidence interval over cases (10 000 resamples, seeded, so the same data always gives the same answer); a metric is a regression only when its interval lies entirely on the bad side of 0, so one flaky case does not fail the build:

from agentlens.compare import compare_experiments
from agentlens.store import SQLiteStore
from agentlens.suite import Suite, run_suite

def agent(question: str) -> str:
    return question.upper()                 # the baseline behaviour

def new_agent(question: str) -> str:
    return question if "refund" in question else question.upper()   # a "prompt change"

cases = [{"id": f"q{i}", "input": f"refund {i}" if i % 3 == 0 else f"hello {i}",
          "expected": (f"refund {i}" if i % 3 == 0 else f"hello {i}").upper()}
         for i in range(30)]
defaults = {"evaluators": ["no_error", "exact_match"]}

with SQLiteStore("runs.db") as store:
    run_suite(Suite(name="demo", agent=agent, cases=cases, defaults=defaults),
              store=store, name="main")
    run_suite(Suite(name="demo", agent=new_agent, cases=cases, defaults=defaults),
              store=store, name="pr-123")
    comparison = compare_experiments(store, "main", "pr-123")   # 10k resamples, seed 0

pass_rate = comparison.metric("pass_rate")
print(f"{pass_rate.delta:+.2f} [{pass_rate.ci_low:+.2f}, {pass_rate.ci_high:+.2f}]",
      pass_rate.status)
print([m.name for m in comparison.regressions])
print([c.case_id for c in comparison.case_regressions][:3],
      comparison.case_regressions[0].regressed_evaluators)
# -0.33 [-0.50, -0.17] regression
# ['pass_rate', 'score.exact_match']
# ['q0', 'q3', 'q6'] ['exact_match']

Cases only in one experiment are listed in added_cases/removed_cases; compare_runs compares two in-memory run_suite results, paired_bootstrap(diffs) is the statistic on its own, and comparison.model_dump_json() is what gates and reports consume. The interval needs about 20+ cases to be meaningful (ADR 0005).

Gates

A gates file sets the thresholds a candidate must meet (FR-S5). Per metric (pass_rate or its alias success_rate, score.<evaluator>, cost_usd/cost, latency_ms/latency, steps): min/max bound the candidate's value, min_delta/max_delta the change vs the baseline and min_delta_pct/max_delta_pct that change in percent. regressions counts metrics whose confidence interval is entirely on the bad side, case_regressions cases whose pass rate dropped. evaluate_gates returns a GateReport whose exit_code is what CI should exit with:

from pathlib import Path

from agentlens.compare import compare_runs, evaluate_gates, load_gates
from agentlens.suite import Suite, run_suite

Path("gates.yaml").write_text("""
success_rate: {min: 0.9}          # alias of pass_rate: candidate value
cost_usd: {max_delta_pct: 20, on_missing: skip}   # +20% at most; skip if no costs
regressions: {max: 0}             # no metric whose CI is entirely on the bad side
""")

def agent(question: str) -> str:
    return question.upper()

def new_agent(question: str) -> str:
    return question if "refund" in question else question.upper()

cases = [{"id": f"q{i}", "input": f"refund {i}" if i % 3 == 0 else f"hello {i}",
          "expected": (f"refund {i}" if i % 3 == 0 else f"hello {i}").upper()}
         for i in range(30)]
defaults = {"evaluators": ["no_error", "exact_match"]}
main = run_suite(Suite(name="demo", agent=agent, cases=cases, defaults=defaults))
pr = run_suite(Suite(name="demo", agent=new_agent, cases=cases, defaults=defaults))

report = evaluate_gates(load_gates("gates.yaml"), comparison=compare_runs(main, pr))
print(report.summary())
print(report.exit_code)
# gates FAILED: 0 passed, 2 failed, 1 skipped

first = evaluate_gates(load_gates("gates.yaml"), run=main)   # no baseline yet: absolute rules only
print(first.passed, first.notes)
# gates FAILED: 0 passed, 2 failed, 1 skipped
#   FAIL success_rate min 0.9: pass_rate 0.6667 is below min 0.9
#   SKIP cost_usd max_delta_pct +20%: no case has a 'cost_usd' value in both experiments
#   FAIL regressions max 0: regressions 2 is above max 0
# 1
# True ['no baseline comparison: 2 delta/regression checks skipped']

A gate whose metric has no data fails with the reason (on_missing: skip per gate, or options: {on_missing: skip} for all, skips it instead). Without a comparison (run= only, e.g. a first run with no baseline yet) delta and regression gates are skipped and noted. Fewer than options.min_cases (default 20) cases adds a note, never a failure. A broken file raises GatesError with every problem at its gates.yaml:line:column.

See also: ADR 0005, design §7, API: agentlens.compare.