Evals & Experiments
Bir includes deterministic local evaluation tools for regression checks. They require neither a server nor an LLM judge.
Run an experiment
from bir.evals import (
Dataset,
DatasetExample,
contains,
exact_match,
latency_under,
run_experiment,
)
dataset = Dataset(
[
DatasetExample(
id="q1",
input={"question": "What is Bir?"},
expected="An observability SDK",
)
]
)
def answer_question(question: str) -> str:
return "Bir is an observability SDK."
result = run_experiment(
"quickstart",
dataset=dataset,
task=answer_question,
evaluators=[
contains(),
exact_match("Bir is an observability SDK."),
latency_under(1000),
],
)
print(result.aggregate_scores)
Results are written to .bir/experiments/*.jsonl, one row per example. A
sibling .summary.json stores the experiment status, counts, aggregate scores,
and result path.
Run experiments concurrently with a thread pool
For I/O-bound synchronous tasks — such as network LLM calls behind a sync
client — pass max_workers=N to run up to N examples at once inside a
concurrent.futures.ThreadPoolExecutor:
result = run_experiment(
"quickstart",
dataset=dataset,
task=answer_question,
evaluators=[contains("observability")],
max_workers=8,
)
Results, JSONL rows, and summary aggregates are always written in dataset
order regardless of which examples finish first. All other semantics —
raise_on_error, record_traces trace isolation, redaction, and the
persisted schema — match the sequential path. The default is max_workers=1,
which runs examples one at a time and is byte-for-byte identical to the
previous behavior.
Run an async experiment
run_experiment_async() is the asynchronous counterpart to run_experiment().
Use it when your task is a coroutine — for example an async provider client — so
you do not have to wrap it in an event-loop adapter. It accepts coroutine
functions, plain sync callables, and sync callables that return an awaitable; a
returned value is awaited only when it is awaitable.
import asyncio
from bir.evals import Dataset, DatasetExample, contains, run_experiment_async
dataset = Dataset([DatasetExample(id="q1", input={"question": "What is Bir?"})])
async def answer_question(question: str) -> str:
# await your async model client here
return "Bir is an observability SDK."
result = asyncio.run(
run_experiment_async(
"quickstart-async",
dataset=dataset,
task=answer_question,
evaluators=[contains("observability")],
max_concurrency=8,
)
)
print(result.aggregate_scores)
Up to max_concurrency examples (a positive integer, default 1) run at once,
but the returned results, the persisted JSONL rows, and the summary aggregates
always follow dataset order regardless of completion order. Evaluator execution,
task input binding, redaction, raise_on_error, record_traces, and the
persisted JSONL/summary schema are identical to run_experiment().
Each example runs in its own asyncio task, so record_traces=True produces an
isolated trace tree per example even when they run concurrently. If the
surrounding coroutine is cancelled, the in-flight example tasks are cancelled and
awaited and CancelledError propagates without writing a summary.
Bound each example with a timeout
Experiments often call real, network-backed model clients, where a single stuck
request can otherwise hang the whole run. Pass timeout=<seconds> (a positive,
finite number) to bound each example. It works on both runners:
result = run_experiment(
"quickstart",
dataset=dataset,
task=answer_question,
evaluators=[contains("observability")],
timeout=30, # seconds per example
raise_on_error=False,
)
An example whose task runs longer than timeout is recorded as an
"error"-status result with a "task timed out after Ns" message — the same
shape as any other failed example, so it counts toward the summary error_count,
honors raise_on_error (raising a TimeoutError when True), and keeps its
place in dataset order. The rest of the run continues.
run_experiment() enforces the limit by running each example on a worker thread
and waiting at most timeout seconds for it; the serial max_workers=1 path
uses a dedicated single-worker executor per example so a timed-out worker never
blocks the next one. Because Python cannot force a thread to stop, a timed-out
synchronous task keeps running in the background until it returns on its own —
its result is simply discarded. run_experiment_async() wraps each example in
asyncio.wait_for(...), so a timed-out example's task is cancelled and awaited
cleanly.
The default is timeout=None (unlimited), which is byte-for-byte identical to
the previous behavior.
Store datasets as JSONL
{"id":"q1","input":{"question":"What is Bir?"},"expected":"An observability SDK"}
from bir.evals import Dataset
dataset = Dataset.from_jsonl("questions.jsonl")
dataset.to_jsonl("copy.jsonl")
Dataset.to_jsonl() redacts common secret-like values by default. To preserve
raw payloads intentionally, pass redact=False.
Evaluator catalog
Available deterministic evaluators are:
| Evaluator | Check |
|---|---|
exact_match() |
Output equals an expected value. |
contains() |
Text contains the expected value. |
similarity_above() |
Fuzzy text similarity ratio to the expected value meets a threshold. |
regex_match() |
Text matches a regular expression. |
json_valid() |
Output is valid JSON or JSON-like data. |
field_equals() |
A dot/index path equals the expected value. |
field_contains() |
A field contains expected text. |
latency_under() |
Measured task duration stays below a limit. |
cost_under() |
An explicit output cost stays below a limit. |
numeric_between() |
Numeric output or field stays in a range. |
retrieved_context_contains() |
Retrieved contexts include expected text. |
answer_context_overlap() |
Answer/context token overlap reaches a ratio. |
answer_contains_citation() |
An answer contains a citation marker. |
custom_evaluator() |
A local callable implements a task-specific check. |
Fuzzy text matching
from bir.evals import similarity_above
evaluators = [similarity_above(0.8, "Bir is an observability SDK.")]
similarity_above() sits between exact_match() and contains(): it scores
1.0 when the normalized difflib.SequenceMatcher ratio between the output text
and the expected text is at or above threshold (inclusive), and 0.0
otherwise. This is a deterministic, standard-library-only check that tolerates
typos, reordering, and minor wording differences without an embedding model or
new dependency. Like contains(), it accepts a per-example expected value when
the expected argument is omitted, and case_sensitive=False lowercases both
sides before comparing. The achieved ratio and threshold are recorded in the
score metadata so failures are inspectable. It is a surface-form heuristic, not
semantic similarity: paraphrases that share few characters can still score 0.0.
Structured output
from bir.evals import field_contains, field_equals, numeric_between
evaluators = [
field_contains("answer", "observability"),
field_equals("citations[0].id", "doc-1"),
numeric_between(min_value=0.7, max_value=1.0, field="confidence"),
]
Field paths support dot paths and list indexes. Missing paths produce a 0.0
score with failure metadata instead of stopping the experiment.
Latency and explicit cost
from bir.evals import cost_under, latency_under, numeric_between
evaluators = [
latency_under(1000),
cost_under(0.05),
numeric_between(min_value=0.0, max_value=1.0),
]
latency_under() uses task duration measured by run_experiment().
cost_under() reads {"total_cost": 0.01} or
{"cost": {"total_cost": 0.01}}. Bir never calculates provider pricing.
RAG heuristics
from bir.evals import (
answer_contains_citation,
answer_context_overlap,
retrieved_context_contains,
)
evaluators = [
retrieved_context_contains("observability"),
answer_context_overlap(0.5),
answer_contains_citation(),
]
These evaluators expect output shaped like
{"answer": "...", "contexts": ["doc text", "..."]}. They are
deterministic heuristics, not proof of retrieval quality, faithfulness, or
citation correctness. Missing inputs produce a 0.0 score with failure
metadata.
Custom evaluators
from bir.evals import EvalResult, custom_evaluator
has_citation = custom_evaluator(
"has_citation",
lambda output, expected: "[1]" in str(output),
)
debuggable = custom_evaluator(
"debuggable",
lambda output, expected: EvalResult(
name="debuggable",
value=1.0,
metadata={"expected": expected},
),
)
Custom evaluator callables may return bool, int, float, or EvalResult.
Their exceptions surface normally.
Load and list results
from bir.evals import list_experiments, load_experiment
loaded = load_experiment(result.path)
summaries = list_experiments()
From the command line, bir experiments lists every experiment under
.bir/experiments/, and bir experiment-show <experiment-id> prints one
experiment's summary (evaluator aggregate means) and a per-example table of id,
status, and scores:
bir experiments # list experiments and aggregate scores
bir experiment-show <experiment-id> # one experiment's summary and results
bir experiment-show <experiment-id> --json # nested object for scripts
Both commands accept --dir to read an experiments directory other than the
default .bir/experiments. bir experiment-show --json emits a deterministic
object with the summary fields and a results list of per-example example_id,
status, scores, and error; an unknown id prints nothing and exits non-zero.
Share a report
bir experiment-report <experiment-id> renders one experiment to a
self-contained, stdlib-only file — the summary, the per-evaluator aggregate
means, and the per-example table of statuses and scores — so you can share or
archive a result without standing up the server or dashboard:
bir experiment-report <experiment-id> # HTML to stdout
bir experiment-report <experiment-id> --format markdown # Markdown to stdout
bir experiment-report <experiment-id> --output report.html # write to a file
The default html format is a complete standalone document with inline styles
and no external assets; --format markdown emits the same sections as a Markdown
document. Like experiment-show it accepts --dir and exits non-zero (printing
nothing to stdout) for an unknown id. Output is deterministic — evaluators are
ordered by name and examples follow dataset order — and every experiment-derived
string is escaped for the chosen format, so already-redacted example text cannot
inject markup. The same rendering is available in Python:
from bir.evals import load_experiment, render_experiment_report
report = render_experiment_report(load_experiment(result.path), format="html")
Compare experiments
Compare aggregate evaluator means against a persisted baseline:
from bir.evals import compare_experiments
diff = compare_experiments("baseline.jsonl", "candidate.jsonl", tolerance=0.01)
print(diff.to_dict())
if diff.has_regressions:
raise SystemExit(1)
A shared evaluator regresses when candidate - baseline drops by more than the
tolerance; a change exactly equal to the tolerance is treated as unchanged.
Evaluators present in only one run are reported separately.
Per-evaluator tolerances
Different scores tolerate different movement. score_tolerances overrides the
global tolerance for individual evaluators while leaving the rest on the
global value:
diff = compare_experiments(
"baseline.jsonl",
"candidate.jsonl",
tolerance=0.01,
score_tolerances={"latency_under": 0.05},
)
Each override value must be a non-negative, finite number, and each name must be
a shared evaluator present in both runs. A name that is not shared (a typo, or
an evaluator that only one run produced) raises a clear error rather than being
silently ignored. diff.effective_tolerances reports the tolerance actually
applied to each shared evaluator.
Missing-score policy
By default, an evaluator that exists in the baseline but not the candidate is
reported under baseline_only without failing the gate (missing_score="ignore").
Because a removed evaluator silently drops coverage, you can opt into treating it
as a regression:
diff = compare_experiments(
"baseline.jsonl",
"candidate.jsonl",
missing_score="regress",
)
Under "regress", every baseline-only evaluator makes diff.has_regressions
true and appears in diff.regression_reasons with the reason "baseline_only".
Delta-based regressions of shared evaluators use the reason
"delta_below_tolerance". Evaluators that appear only in the candidate add
coverage and are never treated as regressions.
Per-example detail
Aggregate means tell you which evaluator regressed, not which examples drove
it — and an unchanged mean can still hide one example dropping while another
improves. Pass per_example=True to also compute, for each shared evaluator, the
candidate-minus-baseline delta of every example scored in both runs:
diff = compare_experiments(
"baseline.jsonl",
"candidate.jsonl",
per_example=True,
)
for evaluator, deltas in diff.example_deltas.items():
for example_id, delta in deltas.items():
if delta < 0:
print(f"{evaluator} dropped {delta:+.2f} on {example_id}")
diff.example_deltas is keyed by evaluator then example_id, both in sorted order.
Examples present in only one run (or not scored by the evaluator, such as an
errored example) are skipped. This is opt-in reporting detail only: it never
changes the aggregate comparison, has_regressions, or the gate exit code. When
per_example=False (the default) example_deltas is empty and is omitted from
to_dict(), so the aggregate-only output is unchanged.
CLI gate
The CLI exposes the same gate and exits 1 exactly when the configured policy
reports a regression. --score-tolerance NAME=VALUE is repeatable and
--missing-score selects the policy:
bir eval-gate baseline.jsonl candidate.jsonl \
--tolerance 0.01 \
--score-tolerance latency_under=0.05 \
--missing-score regress
Repeating --score-tolerance for the same evaluator with the same value is
allowed; conflicting values, malformed NAME=VALUE assignments, and unknown
evaluator names are rejected with a clear error. The emitted JSON includes
effective_tolerances, missing_score, and regression_reasons so the gate
decision is fully machine-readable. Add --per-example to also emit
example_deltas (the same per-example detail as per_example=True above);
without the flag the output is unchanged.
Link results to traces
result = run_experiment(
"prompt-v1",
dataset=dataset,
task=answer_question,
evaluators=[contains()],
record_traces=True,
)
This writes one trace per dataset example and records evaluator outputs as score events.
Upload an experiment
from bir.evals import send_experiment
send_experiment(result.path, "http://127.0.0.1:8000")
The server can then display the experiment list and per-example details.
Like send_events(), send_experiment() retries
transient failures — network errors, timeouts, and HTTP 5xx responses — with
exponential backoff. HTTP 4xx responses, a missing experiment or summary file,
and an invalid success body are permanent and raised immediately.
send_experiment(
result.path,
"http://127.0.0.1:8000",
retries=3,
backoff=1.0,
timeout=10.0,
)
The delay is backoff * 2**attempt. Defaults are two retries, a 0.5-second
backoff, and a 10-second timeout. A healthy send makes one request attempt. The
CLI exposes the same controls as bir send-experiment --retries N --backoff
SECONDS.