Evaluation Engine¶
Core interface¶
from dataclasses import dataclass
from typing import Protocol, Any
@dataclass(frozen=True)
class EvaluationSample:
input: Any
expected_output: Any | None
actual_output: Any | None
context: Any | None
metadata: dict[str, Any]
@dataclass(frozen=True)
class EvaluationResult:
score: float | None
label: str | None
explanation: str | None
metadata: dict[str, Any]
class Evaluator(Protocol):
name: str
async def evaluate(
self,
sample: EvaluationSample,
) -> EvaluationResult: ...
Built-in evaluators — MVP¶
Deterministic¶
- exact_match
- contains
- regex
- json_schema
- hit_at_k (config
k; usesmetadata.expected_doc_idsandcontext.documents) - recall_at_k (same inputs;
|expected ∩ top-k| / |expected|) - mrr (same inputs;
1/rankof first expected id in top-k, else0) - precision_at_k (same inputs;
|expected ∩ top-k| / k; available, not in the default RAG set) - ndcg (same inputs; binary nDCG@k; available, not in the default RAG set)
- latency
- token_usage
- cost
- tool_call_success
LLM judge¶
- answer_relevance
- groundedness
- correctness
- context_precision
- context_recall
- citation_accuracy
- refusal
- pii
- toxicity
- prompt_injection
LLM judges return structured, validated output and are versioned by prompt_version (see LLM judges).
Naming vs Ragas¶
| Ragas | opengrade kind |
|---|---|
| Faithfulness | groundedness |
| Response / answer relevancy | answer_relevance |
| Context precision | context_precision |
| Context recall | context_recall |
| Factual correctness (approx.) | correctness |
Use opengrade kinds in API/config; there is no Ragas-name alias in the registry.
RAG metrics sets and pack scoring¶
Projects hold versioned metrics sets; the default set seeds RAG kinds (hit_at_k, recall_at_k, mrr, context_precision, context_recall, must_contain, groundedness, answer_relevance, correctness, latency, plus citation_accuracy and refusal seeded disabled with thresholds 0.8 / 1.0 — enable them for assistants that cite sources or must decline unanswerable questions — and the safety checks pii, toxicity, prompt_injection seeded disabled with threshold 1.0). POST /experiments/{experiment_id}/evaluate-pack resolves a set (body override → experiment pin → project default), then runs all enabled entries with linked evaluator_id values against experiment item outputs (including OTLP-bound runs). Optional save_as_default persists the request set on the experiment. Calling ensure on an existing project default backfills any missing seed kinds (including newly added ones) without rewriting existing entry thresholds.
Evaluators are shared per kind across metrics sets, so each entry's config (e.g. k, max_ms, judge model) is overlaid on the evaluator config at scoring time (kind cannot be overridden); run metadata records config_override and the effective config_hash. Live scoring applies the same overlay.
Output resolution: once an experiment has any recorded outputs, every item is read from those outputs only — dataset-level actual_output/context are never used as fallback (they belong to another run). Items without an output in that experiment score FAIL (0.0, metadata.missing_output=true) for every evaluator. Experiments with no outputs at all keep reading inline dataset fields (e.g. datasets built from traces).
Explicit POST .../evaluate with evaluator_ids bypasses set resolution.
Groundedness reads retrieved chunk text from run context.documents (populated from SDK set_retrieval_documents, which emits OpenInference flattened attributes retrieval.documents.N.document.*, or from the same flattened keys / reranker.output_documents.N.document.* written by auto-instrumentation such as LangChain or LlamaIndex: content becomes text, metadata.title/file_name the title, and a missing id falls back to metadata.doc_id/document_id/node_id/id; an aggregated retrieval.documents blob, if present, still wins over flattened keys). When a trace has several retrieval stages (RETRIEVER → RERANKER), context.documents is the full list from the last stage that recorded documents (by end time)—including multiple chunks that share a parent doc id. Retrieval metrics (hit_at_k, recall_at_k, mrr, precision_at_k, ndcg) dedupe those ids by first occurrence when ranking.
PASS/FAIL rule (offline runs and live scoring): an item passes only if the evaluator label is not FAIL and the score meets the entry threshold. The stored label is that effective verdict; when it overrides the evaluator's own label, the original is kept in metadata.judge_label (live: appended to the explanation). LLM judges never emit a label, so for them the verdict comes from the threshold alone, with two exceptions: refusal labels every item PASS/FAIL (its score is 0 or 1), the safety checks pii / toxicity / prompt_injection label PASS (clean, 1.0) / FAIL (flagged, 0.0), and citation_accuracy labels FAIL when the answer cites nothing (and require_citations is on) or when min_coverage is not met. A run is FAILED if any item fails.
LLM judges only receive the fields their rubric needs: answer_relevance gets input + answer, groundedness adds context.documents, correctness adds expected_output — for rag_chat items, answer_relevance, groundedness, and correctness also get conversation history when prior turns are present — context_precision and context_recall get input + expected_output + context.documents (no answer), citation_accuracy gets the cited statements + only the documents they cite, refusal gets input + answer + whichever of expected_output / context.documents decides whether the question is answerable, the safety checks get only their configured fields (prompt_injection never gets the answer). Conversation judges: query_rewrite_faithfulness (history + context.search_query), conversation_consistency (full transcript including the latest assistant answer), resolution (history + latest Q/A + last-turn context.documents; skips without documents; turns_to_resolution only when resolved). Item metadata (e.g. expected_doc_ids) is never sent to a judge. Prompt versions are {kind}.{method}.{sha256[:12]} of the system templates that pair uses.
Live scoring: a failing judge records an ERROR score for its kind without discarding the others (score_warning lists the failed kinds; status is error only if every judge failed).
LLM judges¶
Code: opengrade.evaluation.judges (prompts, parsing, claims, rubric, evaluators, cache, snapshots, warnings). Judges return label=None; explanation is a short human-readable summary and structured details go into metadata.
Methods and config¶
| Kind | method |
Notes |
|---|---|---|
groundedness |
claims (default), rubric |
claims: extract answer claims, verify each against context.documents (2 calls) |
correctness |
claims (default), rubric |
claims: extract gold claims from expected_output, check coverage in the answer (2 calls, 4 with scoring: f1; one fewer on a gold-claim cache hit) |
answer_relevance |
rubric (only value) |
one call, anchored 1-5 level |
context_precision |
claims (default), rubric |
claims: one batched relevance verdict per retrieved document vs question + reference; score is RAGAS average precision |
context_recall |
claims (default), rubric |
claims: extract gold claims from expected_output, check coverage in context.documents (2 calls; one fewer on a gold-claim cache hit) |
citation_accuracy |
claims (only value) |
citation markers are parsed locally; each cited sentence is verified against the documents it cites (1 call; 0 when nothing valid is cited) |
refusal |
classify (default), phrases |
classify: one call that labels the answer (and reference) refusal/answer and, without gold, whether the documents contain the answer. phrases: deterministic phrase match, no LLM call |
pii |
classify (only value) |
one call: does the question / answer expose personal data? (categories email, phone, id_number, address, name, financial, other) |
toxicity |
classify (only value) |
one call: hate, insults, threats, sexual content, self-harm (hate, insult, threat, sexual, self_harm, other) |
prompt_injection |
classify (only value) |
one call: do the question / retrieved documents try to manipulate the assistant? (instruction_override, role_play, data_exfil, ignore_context, other) |
rubric for groundedness/correctness/context_recall is a single holistic call intended for small local models; it is never selected as a silent fallback.
Entry config keys (part of config_hash). Invalid values raise ValueError when the evaluator is built.
| key | values | default | applies to |
|---|---|---|---|
method |
claims | rubric |
claims (groundedness, correctness, context_precision, context_recall), rubric (answer_relevance) |
all judges |
scoring |
recall | f1 |
f1 |
correctness with method=claims |
max_claims |
integer >= 1 | 30 | method=claims; extra claims are dropped and claims_truncated=true |
model |
LiteLLM model id | LLM_MODEL |
all |
temperature |
finite float | LLM_TEMPERATURE |
all |
pattern |
regex, first group = cited id(s) | \[([^\[\]\n]{1,200})\](?!\() ([pto], [1, 3], [^2], not markdown links) |
citation_accuracy |
require_citations |
bool | true |
citation_accuracy: an answer citing nothing FAILs (a refusal is SKIPPED) |
min_coverage |
0-1 | none | citation_accuracy: FAIL when fewer sentences carry a citation |
answerable_from |
auto | label | documents |
auto |
refusal: where "should it answer?" comes from |
check |
both | unanswerable | answerable |
both |
refusal: score only the items that should be declined (missed refusals) or only the answerable ones (over-refusals) |
refusal_phrases |
list of strings | English + Italian defaults | refusal with method=phrases (replaces the defaults, whole-word, case-insensitive) |
fields |
non-empty subset of input, output, documents |
["input", "output"] (pii, toxicity), ["input", "documents"] (prompt_injection) |
safety checks: what the classifier reads; output is rejected for prompt_injection |
Scoring¶
- Groundedness (claims):
supported / claims. Each claim issupported,contradictedornot_supported. Zero claims (refusal, "I don't know") isSKIPPED(no_factual_claims). MissingcontextisSKIPPED; emptycontext.documentsis a minimum-scoreFAILoffline (RAG assumption). Live scoring treats empty retrieved documents asSKIPPED(no retrieved documents (live)) so non-retrieval turns are not scored as ungrounded. - Correctness (claims): default
scoring: f1extracts gold claims and answer claims; recall= covered / gold claims, precision= supported / answer claimsagainstexpected_output, score is F1; anycontradictedgold or answer claim gives0.0.scoring: recallskips answer-claim extraction and does not penalize extra non-contradicting statements. Zero gold claims isSKIPPED(no_gold_claims). - Context precision (claims): for each retrieved document (rank order), verdict
relevant/not_relevantvs question +expected_output. Score is average precision (\sum_k (P@k \cdot v_k) / \sum v_k);0when no document is relevant. Optionalconfig.ktruncates the document list;max_claimsis also applied as a safety cap. Missingexpected_outputorcontextisSKIPPED; emptycontext.documentsis a minimum-scoreFAIL. - Context recall (claims): extract gold claims from
expected_output(same gold-claim cache keying as correctness, keyed by this judge'sprompt_version), then verdictcovered/contradicted/missingper claim againstcontext.documents. Score iscovered / gold claims(a contradicted claim is not covered but does not zero the whole score). Zero gold claims isSKIPPED(no_gold_claims). Missingexpected_outputorcontextisSKIPPED; emptycontext.documentsis a minimum-scoreFAIL. Not gold-less: offline pack only. - Citation accuracy: the answer is split into sentences; citation markers (
pattern) are attached to the sentence they follow (a marker after the full stop still belongs to it), lines after aSources:/References:/Fonti:header are a reference list and ignored. Each cited id resolves to a retrieved document byid, thentitle, then 1-based position ([2]). A sentence citing an id that was not retrieved isinvalid_citation(no LLM call). The others are verified against only the documents they cite (supported/contradicted/not_supported, even if an uncited document would support them). Scoresupported / cited sentences;metadata.claimslists every sentence with its verdict,citation_coverageis cited sentences / all sentences. No citation at all: FAIL (or SKIPPED withrequire_citations=false, or when the answer is a refusal). Cited documents without text: SKIPPED. Gold-less: runs on live traffic. - Safety checks (
pii,toxicity,prompt_injection): one structured call over the configuredfields(input= last-turn question,output= answer,documents= retrieved documents with text). A field with no text counts as missing; if none of the configured fields has text the result isSKIPPED(e.g. onlydocumentsand nothing retrieved, orCONTENT_CAPTURE_ENABLED=false), otherwise the judge runs on what is there (prompt_injectionstill scores a question-only turn). They do not need an answer. Score 1.0 /PASSwhen clean, 0.0 /FAILwhen flagged; metadataflagged,categories(unknown values becomeother),evidence(at most 5 snippets of 200 chars),fields_checked,fields_missing,reasoning. Gold-less: they run on live traffic. No regex/detector method and no redaction yet. - Refusal: is the question answerable?
autouses, in order, the dataset label (metadata.answerable, ormetadata.expected_refusal/should_refuseinverted; bools or"true"/"false"strings), the reference answer (a reference that declines means "should refuse"), then the retrieved documents (judge decides whether they contain the answer; an empty document list means "should refuse"). Then score 1 forcorrect_answer/correct_refusal, 0 forover_refusal(declined an answerable question) /missed_refusal(answered one it should have declined: likely hallucination);metadata.outcome,expected_answerable,refused,answerable_source. No source: SKIPPED. On live traffic (no gold) it reads the documents, so enable it there only for retrieval turns. Usegroup_by/ the outcome metadata, or two entries withcheck: unanswerableandcheck: answerable, to see hallucination rate and over-refusal rate separately. - Rubric:
{reasoning, level}withlevel1-5 andscore = (level - 1) / 4. - Missing
actual_outputisSKIPPEDfor the answer judges (groundedness, correctness, answer_relevance). context_precision and context_recall never read the answer, so they also score retrieval-only runs (og.evaluate_retrieval); they, and correctness, needexpected_output. - PASS/FAIL comes only from the entry threshold.
Determinism and versioning¶
LLM_TEMPERATURE(default0.0) andLLM_SEED(default42) are sent on every judge call;LiteLlmClientpassesdrop_params=Trueso providers withoutseedignore it. Entrytemperature/modeloverride them per entry.prompt_version = "{kind}.{method}.{sha256[:12]}"is derived from a canonical JSON hash of the system prompt templates that(kind, method)uses (e.g.groundedness.claims.a1b2c3d4e5f6). Changing any of those templates changes the hash automatically — no manual revision bump. The id is stored in each result's metadata and inrun.metadata.prompt_version. Compare flags aprompt_version/judge_model/config_hashmismatch between candidate and baseline as a config mismatch (flagged, not blocked).- For personal audit, the first time a judge scores an item the full prompt bundle is upserted into a local gitignored JSON file (default
.opengrade/judge_prompt_snapshots.json, override withOPENGRADE_JUDGE_PROMPT_SNAPSHOTS). Entries are keyed by the full SHA-256. Constructing a judge (e.g. validating project-default metrics) does not write it. The file is not shared and is not required for compare/cache — those use the hash in metadata only. - Result metadata:
method,prompt_version,model,claims(per claimtextandverdict, plusdoc_ids/quote/reasoningwhere applicable), counts (n_claims,n_supported,n_contradicted; correctness:n_gold,n_covered,n_missing,recall, andprecisionfor f1),claims_truncated,llm_calls,cache_hit. Rubric results storelevelandreasoning. - Explanations are capped at 1000 characters (claim lists are abbreviated).
Gold-claim cache¶
Gold claims extracted from expected_output are stored in judge_claim_cache, keyed by a SHA-256 of question + expected output + prompt_version + model. A hit skips the extract call, so candidate and baseline experiments are scored against identical gold claims. Entries are written only after the extract output passed validation and produced at least one claim; empty extractions are not cached, and a stored empty list is treated as a miss so a flaky empty extract cannot lock an item into permanent no_gold_claims SKIPPED.
Output validation and errors¶
Every step parses tolerantly (code fences, JSON wrapped in prose) and validates against a schema (enum verdicts, level 1-5, one verdict per claim sent). On failure, a format-only repair call rewrites the invalid draft into schema-shaped JSON (max 2 LLM calls per step; the judge task is not re-asked). Failures after repair produce an ERROR result with metadata.error_type:
judge_output_invalid: the model did not follow the format after format repair;metadata.raw_outputholds the first 2000 characters.llm_not_configured: provider auth failure (bad/missing credentials after a call). Missing model/API key before any call is SKIPPED instead (metadata.skip_reason: llm_not_configured); it does not fail the experiment.llm_unavailable: timeout, connection, rate-limit or provider 5xx after LiteLLM retries.context_overflow: context window exceeded. Documents are never truncated silently.llm_error: any other provider error.
Minimum judge model¶
- Dev / smoke / local Ollama:
ollama/gemma4:e2bis a workable minimum forclaimsjudges: it discriminates good vs wrong_fact vs hallucination on the HR sanity suite, with format repair covering most schema glitches. - Quality gates / CI / production scoring: prefer a stronger chat model that reliably emits structured JSON, or set
method: rubricon groundedness/correctness/context_* for small models. There is no silent fallback fromclaimstorubric. - Verify a candidate judge with
scripts/judge_sanity.py(and optionallyscripts/judge_smoke.py) using the sameLLM_MODEL/LLM_API_BASEas the API.
Unsuitable-model warning¶
- Offline: when
judge_output_invaliditems exceed 20% of evaluated items (SKIPPED and missing-output items excluded; the other error types do not count as format failures), the run getsmetadata.warnings = ["judge_model_unsuitable"]andmetadata.warning_detail(model,method,failure_rate,message). The remedy ismethod: rubricor a stronger judge model. - Live: an interaction whose judge result is
judge_output_invalidgets ajudge_model_unsuitablescore_warning. The list endpoint also returnsjudge_warningscomputed over the last 50 live scores per kind (unfiltered by list query params), using the same >20% rule as offline runs.
Manual smoke test¶
scripts/judge_smoke.py runs the built-in judges on a fixed sample against a real model (not part of CI):
cd backend
LLM_MODEL=ollama/gemma4:e2b LLM_API_BASE=http://localhost:11434 \
uv run python ../scripts/judge_smoke.py [--method rubric]
A capable model should give groundedness about 0.5, correctness about 0.5 and answer_relevance 1.0.
Judge sanity suite (meta-eval)¶
scripts/judge_sanity.py scores the fixed gold set in scripts/judge_sanity/knowledge.json under synthetic variants (good, wrong_fact, hallucination, bad_retrieval, refusal) and checks aggregates against scripts/judge_sanity/judge_sanity_thresholds.yaml. On ERROR / judge_output_invalid it prints kind, item index, explanation, and truncated raw_output to stderr for diagnosis. In-process judges only — no API server. Not part of default PR CI.
cd backend
LLM_MODEL=ollama/gemma4:e2b LLM_API_BASE=http://localhost:11434 \
uv run python ../scripts/judge_sanity.py [--variants good,wrong_fact] [--method rubric]
Optional GitHub Actions template: .github/workflows/judge-sanity.yml.example (workflow_dispatch; copy to judge-sanity.yml and set LLM_* secrets).
Evaluator execution¶
Dataset
│
▼
Scheduler (or evaluate-pack / metrics set)
│
├── evaluator A ──► results
├── evaluator B ──► results
└── evaluator C ──► results
│
▼
Aggregation
│
▼
Experiment / Run
Scoring¶
Every evaluator must document: - score range - higher/lower-is-better semantics - threshold compatibility - whether scores are deterministic - provider/model dependency
Reproducibility¶
An evaluation run stores: - evaluator name/version - configuration hash - dataset version - model/provider - prompt/template version - execution timestamp - application version
Failure handling¶
Do not use score=0 for infrastructure failures or non-evaluable inputs.
Use explicit states:
Outcome classes:
- ERROR — judge/infra failure (timeout, auth, exception).
scoreis null; run becomesERRORand the experiment becomesfailed. - SKIPPED — metric cannot be evaluated (missing gold / required fields, or judge model/API key not configured).
scoreis null; excluded from mean/pass_rate; does not fail the experiment. - FAIL with minimum score (usually
0.0) — quality miss, including empty/missing retrieval when gold is present. Lowers mean/pass_rate and can fail gates.
Helpers: opengrade.evaluation.outcomes (skip, fail_min, error, pass_).
LLM judge requirements¶
- structured JSON output
- timeout
- retry policy
- max concurrency
- token/cost accounting
- model/provider recorded
- optional redaction
- judge prompt version recorded
Extension point¶
Evaluator registry (evaluation/registry.py):
This registry is the primary open-source extension surface: contributors add evaluators here, not by forking API or UI code. How to write, register and test one: 16-custom-evaluators.md. App-specific checks that should not live in the server go in the SDK (@opengrade.metric).
Not yet: loading out-of-tree evaluator packages without modifying the core (e.g. Python entry points).