Evaluation harness (§56)¶
Because state is structured, evaluation is multi-level — not just
answer == expected:
Evidence quality · Claim verification · Confidence calibration · Provenance grounding · Calculation correctness · Answer coverage · Source coverage
reactifact.eval is the deterministic, LLM-free harness that scores a run's final
state. Each metric is a pure function over the resulting Context (+
optional ground truth), so truthfulness is measured where it lives — the
artifact graph — not the smoothness of the text.
Running a suite¶
from reactifact.eval import EvalCase, calculation_correctness, core_metrics, run_suite
cases = [
EvalCase(
name="calc-question",
run=_run_knowledge_calc, # executes the pipeline → Context
expected={"sources": ["costs:", "pricing:", "guide:"]},
),
]
report = run_suite(cases, metrics={
**core_metrics, # answer/provenance/evidence/claim
"calc": calculation_correctness(values=(5480, 3580)),
"sources": source_coverage(),
})
print(report.render())
eval · multi-level report (§56)
[calc-question] overall 1.000
answer_present 1.000
provenance_grounded 1.000
evidence_quality 1.000
claim_verification 1.000
calc 1.000
sources 1.000
suite overall: 1.000
Metrics¶
Artifact classes are matched by name (Answer, Evidence, …), so the
harness needs no domain imports — the domain stays out of the framework.
| Metric | What it answers | Built-in |
|---|---|---|
answer_present |
did the run produce an answer at all? | plain fn |
provenance_grounded |
is every answer backed by an existing supported_by (§34)? |
plain fn |
evidence_quality(threshold=0.5) |
share of evidence scoring at/above the bar | plain fn |
claim_verification(valid=("verified",)) |
share of claims that passed verification (§35) | plain fn |
confidence_calibration() |
Brier score of Claim.confidence against actual correctness (§56) |
factory (needs expected.claim_correctness) |
answer_coverage() |
coverage of the expected answer text by the answer | factory (needs expected.answer) |
calculation_correctness(values=…) |
share of calculations matching ground truth (§67) | factory |
source_coverage() |
share of the answer's sources matched by markers | factory (needs expected.sources) |
core_metrics bundles the four non-generative ones. A metric that lacks ground
truth returns None and is reported as skipped (EvalResult.skipped), never
as a silent zero.
Dataset evaluation¶
Beyond scoring one already-built Context, reactifact.eval runs a dataset
through a target and scores every example — the offline-evaluation loop,
with no hosted store:
from reactifact.eval import (
Dataset, evaluate, from_metric, judge_correctness, summary_pass_rate,
)
dataset = Dataset.from_file("evals/qa.json") # or from_list / .jsonl
report = evaluate(
dataset,
target=lambda inputs: my_pipeline(**inputs), # Context | ScenarioResult | {outputs} | RunResult
evaluators={
"present": from_metric("answer_present", answer_present),
"coverage": from_metric("coverage", answer_coverage()),
"correctness": judge_correctness(llm), # P2: LLM-as-judge
},
summary=[summary_pass_rate(0.8)],
)
report.assert_passed({"correctness": 0.7}) # CI gate (raises EvalFailure)
Example carries inputs (fed to the target), optional reference_outputs
(used only by evaluators) and metadata; its id is content-derived, so
Dataset.version pins a run to a dataset revision. A target may return a
Context, a ScenarioResult from reactifact.testing (read via .context/
.trace), a plain outputs mapping, or a RunResult. evaluate runs examples
sequentially in dataset order (max_concurrency=N parallelizes I/O-bound
targets). Pass on_result=callback to receive each EvalResult as soon as it
is scored — a progress hook for long suites; with max_concurrency > 0 the
callbacks fire in completion order. The returned EvalReport is unchanged.
report = evaluate(
dataset,
target=my_pipeline,
evaluators=evaluators,
on_result=lambda result: print(f"{result.case}: {result.overall():.2f}"),
)
Evaluators¶
An Evaluator is any callable EvalInput -> Feedback | bool | float |
{key: score} | list | None; None skips (no ground truth / not applicable
— never a silent zero). EvalInput exposes the example, the extracted
outputs, and — for reactifact runs — the final context and trace.
from_metric wraps any scoring metric into an evaluator:
evaluators = {
"grounded": from_metric("provenance_grounded", provenance_grounded),
"calc": from_metric("calc", calculation_correctness(values=(5480, 3580))),
}
trajectory_match(mode, steps=…) compares the path a run took against
example.reference_outputs["trajectory"] — the typed-graph analogue of
message-trajectory matching. steps is "agents" (the agent path),
"events", "reads"/"writes" ("create:Answer", …) or a custom
EvalInput -> list[str]:
| Mode | Meaning |
|---|---|
strict |
same steps, same order |
unordered |
same multiset of steps |
subset |
actual ⊆ expected (no unexpected steps) |
superset |
expected ⊆ actual (at least the required steps) |
LLM-as-judge¶
For subjective quality there is no deterministic metric; an LLM grades the
output against a rubric. llm_judge turns any LLMProvider into an
Evaluator, so a judge is just another model call — it composes with
CachingLLM/budget/metrics and is faked in tests with FakeLLM:
from reactifact.eval import judge_correctness, judge_faithfulness, judge_relevance
judge_correctness(llm, continuous=True, choices=[0.0, 0.5, 1.0]) # reference-based
judge_relevance(llm) # reference-free: does it answer the question?
judge_faithfulness(llm) # reference-free: grounded in the context?
The judge is asked for strict JSON ({"score": …, "comment": …}); parsing is
tolerant (scans for the first JSON object, then a number/boolean). continuous
yields a 0..1 float; choices snaps to a fixed scale; include_reference
formats the reference outputs into the prompt (reference-based grading).
Gate & summary¶
EvalReport.aggregate() is the mean per metric key; passed(…) /
assert_passed(…) (or module-level assert_eval) is the CI gate — a threshold
for a key no case measured is not met. Summary evaluators
(summary_pass_rate(threshold), summary_mean(key)) aggregate across the whole
suite into EvalReport.summary.
Online evaluation¶
The same evaluators can score live runs instead of a curated dataset:
reactifact.eval.online samples finished runs from a TraceStore, scores them,
and writes the verdicts back as tags (the reviewer-annotation channel the
dashboard already reads) plus metrics.
from reactifact.eval import (
OnlineEvalConfig, OnlineEvaluator, judge_relevance, output_present,
)
from reactifact.tracing import TraceStore
store = TraceStore("traces.db")
evaluator = OnlineEvaluator(
store,
OnlineEvalConfig(
evaluators={"present": output_present(), "relevance": judge_relevance(llm)},
sample_rate=0.1, # score 10% of matching runs
tag="eval", failure_tag="eval:failed",
),
metrics=metrics, # any reactifact.metrics.Metrics
)
await evaluator.run_once() # one batch
# or, in a FastAPI lifespan task:
await evaluator.run_forever(interval_seconds=300)
A trace is a summary — artifact data and LLM messages are truncated for storage — so a source decides what is scoreable:
trace_source()(default): scores theRunTraceitself — the run's typed path (trajectory_match), andoutput_present/no_errorsover the outputs and spans. A metric that needs data the trace no longer carries is skipped.context_source(run_fn): the app rebuilds the fullContextfor a run id, socore_metrics,from_metric(...)and judges see complete data.OnlineEvalConfig.reference_fn(run)supplies per-run ground truth (e.g. an expected trajectory) when scoring a reference-based metric.
sample_rate/strategy/seed choose which runs (reproducible under a seed);
on_report observes each batch. FastAPI apps can mount
create_online_eval_router(evaluator) — POST /api/evals/run,
GET /api/evals/report, GET /api/evals/summary — and the CLI scores a store
in one command:
reactifact eval traces.db --sample 1.0
reactifact eval traces.db --evaluators judge --provider openai:gpt-4o-mini
Types¶
Metric— one measured 0..1 score with a reportingweight.EvalCase— name + arun() -> Context+ optionalexpectedground truth.EvalResult(per case) /EvalReport(suite) —overall()(weighted mean),aggregate(),passed(),to_dict(),render().Example/Dataset— versioned evaluation examples + JSON/JSONL loading.EvalInput/Feedback/Evaluator— an evaluator's inputs and verdict.RunResult— a normalized target result (outputs+ optional context/trace).EvalFailure— raised by the gate (AssertionErrorsubclass).
Where to look¶
tests/test_eval.py scores the knowledge calculation question offline (no
LLM); tests/test_eval_dataset.py, test_eval_trajectory.py and
test_eval_judge.py cover the dataset loop, trajectory matching and the judge
(a FakeLLM returning JSON).