Table of Contents
- Evals Setup
- Creating an Eval Set
- Example Eval Set
- Running Evals
- Datasets
- Adding Result Metadata to Expectations
- Ground Truth Matchers
- Scores
- LLM-as-Judge Expectations
- Expecting Tool Calls
- Reading One Run
- Comparing Runs
Evals Setup
Raif includes the ability to create and run LLM evals to help you iterate, test, and improve your LLM interactions/prompts.
Evals are automatically set up when you run the install command during setup. If you need to set up evals manually, you can run:
bundle exec raif evals:setup
This will:
- Create a
raif_evalsdirectory in your Rails project with asetup.rbfile. This file is loaded automatically when you run your evals. - Within
raif_evals, it will also create the following directories:
Creating an Eval Set
Raif’s generators for tasks, conversations, and agents will automatically create a related eval set for you. To create an eval set manually, you can run:
rails g raif:eval_set MyExample
This will create raif_evals/eval_sets/my_example_eval_set.rb. Each eval set is made up of:
- A
setupblock that runs before each eval - A
teardownblock that runs after each eval - One or more
evalblocks, each containing:- A description of the eval
- One or more
expectblocks that return true or false to indicate if the eval passed or failed
The expect blocks in a Raif eval are similar to expectations/assertions in a normal test suite. But unlike test suite expectations/assertions, a failure in an expect block will not terminate the eval. Your evals are expected to run against an actual LLM (costing you API bills), so this allows you to test multiple expect blocks via a single API call, even if some of them fail.
Example Eval Set
Below is an example eval set for the Raif::Tasks::DocumentSummarization task created in the tasks docs.
class Raif::Evals::Tasks::DocumentSummarizationEvalSet < Raif::Evals::EvalSet
# Setup method runs before each eval
setup do
# Assumes your app has a User model
@user = User.create!(email: "test@example.com")
end
eval "Raif::Tasks::DocumentSummarization produces expected output" do
# Assumes your app has a Document model
document = Document.create!(
title: "Example Document",
content: file("documents/example.html"), # assumes a file exists at raif_evals/files/documents/example.html
creator: @user
)
task = Raif::Tasks::DocumentSummarization.run(
creator: @user,
document: document,
)
expect "task completes successfully" do
task.completed?
end
summary_word_count = task.parsed_response.length
expect "summary is between 100 and 1000 words", result_metadata: { word_count: summary_word_count } do
summary_word_count.between?(100, 1000)
end
basic_html_tags = %w[p b i div strong]
expect "contains basic HTML tags in the output" do
basic_html_tags.any?{ |tag| task.parsed_response.include?("<#{tag}>") }
end
# Use LLM to judge the clarity of the summary
expect_llm_judge_score(
task.parsed_response,
scoring_rubric: Raif::Evals::ScoringRubric.clarity,
min_passing_score: 4,
result_metadata: {
compression_ratio: (document.content.length.to_f / summary_word_count).round(2)
}
)
end
eval "handles documents that are too short to summarize" do
# Assumes your app has a Document model
document = Document.create!(
title: "Example Document",
content: "short doc",
creator: @user
)
task = Raif::Tasks::DocumentSummarization.run(
creator: @user,
document: document,
)
expect "returns exactly the text 'Unable to generate summary'" do
task.parsed_response == "Unable to generate summary"
end
end
end
Running Evals
To run your evals, you can run:
# Run all eval sets
bundle exec raif evals
# Run a specific eval set file
bundle exec raif evals ./raif_evals/eval_sets/my_eval_set.rb
# Run a specific eval block by line number
bundle exec raif evals ./raif_evals/eval_sets/my_eval_set.rb:23
# Run multiple files
bundle exec raif evals ./raif_evals/eval_sets/file1.rb ./raif_evals/eval_sets/file2.rb:15
# Run each eval 5 times and report a pass rate for each
bundle exec raif evals --repeat 5
# Restrict a dataset run to specific cases, or a random sample of them
bundle exec raif evals --cases climate-report,earnings-call
bundle exec raif evals --sample 5 --seed 42
# Run 8 evals at a time instead of one after another
bundle exec raif evals --concurrency 8
# Print every expectation for every case rather than one line per case
bundle exec raif evals --verbose
# Force the compact one-line-per-case output, even if your initializer turns verbose on
bundle exec raif evals --no-verbose
# Pick up an interrupted run where it stopped, without paying for its results again
bundle exec raif evals --resume raif_evals/results/eval_run_20260805_094122_anthropic_claude_5_sonnet.partial.jsonl
--cases, --sample, and --seed only affect evals that have a dataset; see Selecting Cases to Run.
By default, evals are run against your Rails test environment & database. Each eval is run in a database transaction, which will be rolled back at the end of the eval.
While Raif makes it intentionally difficult to run your normal test suite using real LLM provider API keys, the nature of evals makes it essential that actual API keys are available. When running evals, Raif will load API keys from your initializer, as described in the setup docs.
Results
Once your evals have run, a JSON file will be created in raif_evals/results with the results of each eval. The filename and the file’s configuration block both record the model the run used, so results from different models can be told apart:
{
"run_at": "2026-08-02T18:14:22Z",
"configuration": {
"default_llm_model_key": "open_ai_responses_gpt_5_6_terra",
"evals_default_llm_judge_model_key": "anthropic_claude_5_sonnet",
"judge_model_key": "anthropic_claude_5_sonnet",
"repeats": 5,
"capture_model_completions": "full",
"cases": null,
"sample": null,
"seed": null,
"datasets": [
{ "eval_set": "SummarizationEvalSet", "name": "documents", "cases": 24, "digest": "sha256:9f2c..." }
],
"code": { "git_sha": "4a91c0b7e2d3f8a6c5b4e3d2a1908f7e6d5c4b3a", "dirty": false }
}
}
run_at is when the run started, which is also the timestamp in its filename. evals_default_llm_judge_model_key is the judge that was configured and judge_model_key is the one the run actually used, which are the same thing unless no judge was configured - in which case the setting is null and judge_model_key is the model under test.
What Was Measured
Two keys in the block record the inputs rather than the settings, so a later reader can tell a model that changed from a dataset that did.
datasets has one entry per dataset the run used: the eval set that declared it, its name, how many cases it holds, and a SHA-256 digest over those cases. The digest is taken over the cases sorted by id, with the keys inside each case sorted too, so reordering rows or reformatting the file leaves it alone while editing an input, an expected, or the set of cases changes it. A run that used no dataset records an empty list, which is how a reader tells it from a run written before this was recorded. When --cases or --sample narrowed a dataset, the entry also carries selected; cases is always the whole dataset, since the selection itself is already recorded above.
This matters because evals:compare joins the two runs on case id. Edit one line of documents.jsonl, re-run, and without the digest the difference is reported as the model behaving differently on the same input. With it, the comparison warns that the datasets are not the same before you read anything else. --resume is stricter: a dataset whose contents changed while the run was interrupted is refused outright, since the two halves of the results file would describe different inputs under one case id. Only the datasets both the log and the resuming invocation resolved are compared, so a resume narrowed to one eval set file still works.
code is the host app’s git HEAD and whether the working tree was dirty, or null when the app is not a git checkout. Comparing one model before and after a prompt change is one of the two workflows evals:compare exists for, and nothing else in the results says which side of the change a run was on. Unlike everything else in the block, it does not constrain --resume: the commit that landed while a run was interrupted is often the one that fixed whatever interrupted it, so a resume across a commit warns and carries on rather than refusing.
Alongside run_at and configuration, the file has two more top-level keys:
results- one entry per eval set, each an array with one result per execution of an eval block. A result carries itsdescription,eval_id,eval_index,passed,expectation_results, anyscores, itsusageandmodel_completions, plus arun_indexfor repeats and acase_idfor dataset cases. A result that raised also carrieserrored: true- see Errors Are Not Failures.summary- run-wide totals across every eval, plus aneval_pass_ratesarray with one row per eval and ascore_summariesarray with one row per score name per eval.
This file is what evals:compare and evals:report read, so keep the runs you want to diff against or read back later.
Eval Ids
Each result carries an eval_id, which is what identifies that eval across runs - the key evals:compare matches a baseline result to its candidate on, and the key --resume skips already-recorded work by. It looks like this:
SummarizationEvalSet#summarizes-the-article-7f3a91c0b4e2
Three parts: the eval set’s class name, a slug of the eval’s description, and the first 12 hex characters of SHA256("<eval set class name>\n<description>"). The digest is what actually identifies the eval - it’s taken over the description verbatim, so "handles > 100 items" and "handles < 100 items" stay distinct even though they slug identically - and the slug in front of it is there so a result row or a NOT COMPARABLE line is recognizable at a glance. The slug is capped at 60 characters; the digest is not affected by that.
Nothing to declare, but two consequences worth knowing:
- Two evals in one eval set can’t share a description. Their ids would be identical, so their results would be joined as though they came from the same eval. Raif refuses at load time rather than blending them, and names the description to reword.
- Rewording a description produces a new id. A comparison then reports the old eval as disappearing and the new one as arriving, rather than diffing the two as the same eval. That’s usually right - a reworded eval is usually a changed eval. When it isn’t, pass
id:to keep the old identity across the rewording:
eval "counts words, ignoring markdown syntax", id: "word-count" do
# ...
end
A declared id: replaces the slug-and-digest half, so the example above is SummarizationEvalSet#word-count. It has to be unique within the eval set, and may contain letters, numbers, and any of _ . : -.
eval_index, also on every result, is the eval block’s position in its set. It’s what puts results back into definition order within one run, and is not an identity: inserting an eval block shifts every index below it.
Errors Are Not Failures
An eval can end three ways, not two. It can pass, it can fail, or it can raise - a 429 from the provider, a socket timeout, a bad fixture in setup, a JSON::ParserError on a malformed response. The first two are measurements of your model’s output. The third is not a measurement at all, and Raif keeps it separate everywhere:
- The result carries
errored: truealongsidepassed: false, and the expectation that raised has"status": "error"rather than"failed". - The console prints
!in yellow for that case rather than a red✗, and the run summary countsN erroredon its own line rather than folding it intoN failed. - Errored runs leave the pass-rate denominator. An eval that ran 4 times, passed 3 and errored once reports
3/3- apass_rateof1.0, not0.75.summary.eval_pass_ratesrows carry anerroredcount next torunsandpassedso the denominator is legible. - When every run of an eval or a case errored, its
pass_rateisnullrather than0.0. Nothing was measured, and a zero would claim it all failed.evals:comparereports those under NOT COMPARABLE, the same as a case only one run has.
evals:compare reports what each run lost to errors as its own section, so a run that got worse and a run that got flakier are not the same finding:
ERROR RATES (1)
DocumentSummarizationEvalSet produces expected output
0.00 -> 0.12 0/24 -> 3/24 runs errored
NOT COMPARABLE (1)
DocumentSummarizationEvalSet produces expected output
quarterly-report candidate: all 3 runs errored
SUMMARY
evals errored 0/24 (0.0%) -> 3/24 (12.5%)
gate declined: 12.5% of runs errored, above the 5% ceiling (exit 2)
The reason for all of this is CI. If an error counts as a failure, a rate-limited afternoon reads as a model regression and fails the build, and the usual response to that is to disable the gate. evals:compare goes one step further and refuses to gate at all when too many runs errored.
Errors are also retried before they get this far: transient provider failures (rate limits, timeouts, 5xx) are retried with jittered exponential backoff, honoring a Retry-After header when the provider sends one. What reaches the results file is what survived that.
Resuming an Interrupted Run
The results file above is written once, after every eval set has finished. On its own that would mean a run killed at case 48 of 50 - by Ctrl-C, a provider outage, a rate limit cascade, the process running out of memory - loses every result it already paid for.
So each result is also appended to a run log the moment it completes, at raif_evals/results/<run name>.partial.jsonl. It’s JSON Lines: a header line identifying the run and listing the executions it set out to perform, then one line per eval result. A run that stops early tells you what it has and how to pick it up:
Run interrupted.
38 results were recorded before it stopped: raif_evals/results/eval_run_20260805_094122_anthropic_claude_5_sonnet.partial.jsonl
Resume with: bundle exec raif evals --resume raif_evals/results/eval_run_20260805_094122_anthropic_claude_5_sonnet.partial.jsonl
--resume (or RAIF_EVAL_RESUME) reads the log back and skips every execution it already holds, keyed on the same tuple that identifies a result in the JSON: which eval block, which dataset case, which repeat. Because that key is the eval’s id rather than its position, editing the file around an eval - adding one above it, reordering - doesn’t confuse a resume about which results it already has. Only the work that never finished costs anything the second time. The resumed run completes the results file its first attempt was headed for - same name, same run_at - rather than opening a second file describing the same run, and the log is deleted once that file exists.
Some specifics worth knowing:
- A sampled run resumes on the seed it recorded.
--samplewithout--seedstill writes the seed it drew into the log header, and the resumed invocation samples on that rather than drawing again, so it continues the same subset of cases. Passing an explicit--seedthat differs from the logged one is a configuration mismatch and is refused, per the next point. - The whole configuration has to match. Every key in the results
configurationblock either changes what a result means (which model produced it, which model judged it, how much of each call was captured) or which cases produce one (cases,sample,seed,repeats). A resume that let any of them drift would write one results file describing a run that never happened, so a mismatch is refused and names the keys that moved. Re-run with the settings the log was started with, or drop--resumeto start fresh. - An edited dataset is refused; edited code is only reported. A dataset whose fingerprint moved is a configuration mismatch like any other, since the two halves of the results file would describe different inputs under one case id. The
codekey is deliberately not held to that standard - the commit that landed while a run was interrupted is often the one that fixed whatever interrupted it - so a resume across a commit prints a warning and carries on. The results file then names one commit for work produced under two, which is why it says so. - A truncated last line is skipped, not fatal. A hard kill lands mid-write. Losing the one result being appended is the cost of appending; losing the run over it would defeat the purpose.
- Results are carried forward for eval sets the resumed invocation doesn’t visit. Resuming with a narrower set of files keeps what the log already holds for the others.
-
The log, not the invocation, decides when the run is finished. The header records the run’s plan: every eval id, dataset case and repeat the run set out to execute. The results file is written and the log deleted only once a result exists for all of them. So a resume narrowed to one eval set file runs that file, then reports what is still outstanding and leaves the log in place:
Run incomplete: 12 of 50 planned evals have not run, so no results file was written. DocumentEvalSet: 12 38 results are recorded: raif_evals/results/eval_run_20260805_094122_anthropic_claude_5_sonnet.partial.jsonl Finish the run with: bundle exec raif evals --resume raif_evals/results/eval_run_20260805_094122_anthropic_claude_5_sonnet.partial.jsonlAn eval block added to a file while the run was interrupted is work the run now owes too: the resume appends it to the plan and the run isn’t finished until it has run.
- A log written without a plan is refused. Its finished work can’t be told from its outstanding work, so there is no safe way to finish it. Start a new run.
- A run that stopped before recording anything deletes its own log, since there is nothing there to resume.
If you keep result files in version control, add raif_evals/results/*.partial.jsonl to your .gitignore - a log is transient, and the run it belongs to either finishes and replaces it or gets resumed.
Running Evals Concurrently
An eval run is almost entirely spent waiting for provider responses. A 30-case dataset at --repeat 5 with a judge call per case is 300 sequential round trips - half an hour in which your CPU does nothing. --concurrency N (or RAIF_EVAL_CONCURRENCY=N, or Raif.config.evals_concurrency in your initializer) overlaps that waiting across N threads:
bundle exec raif evals --concurrency 8
The default is 1, and the serial path is unchanged: same order, same output, no threads.
The whole run’s work - every eval, every dataset case, every repeat, across every eval set - is listed before any of it executes, so the threads stay busy across eval set boundaries rather than draining a pool at the end of each set. Raising concurrency changes nothing about what a result means, which is why --resume will happily resume a run at a different concurrency than the one that started it.
Before turning it up, three things need to be true:
- Your database connection pool has to be bigger than the concurrency. Each eval takes a connection for the transaction it runs in, so
--concurrency 8against the defaultpool: 5would spend the run timing out on checkouts rather than on inference. Raif checks this at startup and refuses to run rather than letting you find out at case 40; raisepool:for your test environment inconfig/database.yml. On sqlite3 concurrency is capped to 1 instead - concurrent write transactions against one file serialize onSQLITE_BUSY, so the threads would only add contention. - Your provider rate limit has to absorb it. Concurrency turns 429s from rare into routine. Raif retries them with exponential backoff and honors a
Retry-Afterheader when the provider sends one (seeRaif.config.llm_request_max_retries), but a concurrency well past your tokens-per-minute limit just converts wall clock into retry sleep. Start around 4-8 and watch for retries in the logs. - Your evals have to be independent of each other. They already need to be - each eval runs in its own transaction that is rolled back - but concurrency makes it visible: two evals running at once are in two uncommitted transactions on two connections, so neither can see what the other created. An eval that depends on data another eval’s
setupleft behind was already relying on something Raif does not promise, and will start failing here.
Two things about the console output change:
- Lines arrive in completion order, not definition order. Every line carries its case id, and each eval’s description is printed the first time one of its results lands. The results file is unaffected: it is always written back in definition order, so two runs of the same work produce the same file whatever concurrency produced them.
- Each execution’s lines are written as one block. A case summary and the failing expectations beneath it are emitted together rather than being interleaved with whatever else finished at the same moment.
Ctrl-C still works. Workers stop before picking up their next execution and the in-flight ones are allowed to finish and be recorded, so everything already paid for reaches the run log and the run stays resumable.
Repeating Evals
LLM responses vary between runs, so a single pass/fail per eval cannot separate a real quality difference from one unlucky sample. --repeat N (or RAIF_EVAL_REPEATS=N) runs each eval N times, re-running setup and the eval block for each so the repeats are independent samples rather than a re-scoring of one response.
Repeats sample the model, not your inputs. To vary the input as well, give the eval a dataset - the two compose, and the run becomes cases × repeats.
Each result gains a run_index, and the run’s summary gains an eval_pass_rates array with one row per distinct eval, keyed on eval_id:
{
"eval_set": "MyEvalSet",
"description": "produces expected output",
"eval_id": "MyEvalSet#produces-expected-output-9c1de4a70b2f",
"runs": 5,
"errored": 0,
"passed": 4,
"pass_rate": 0.8
}
errored counts the runs that raised rather than producing a measurement; they are excluded from pass_rate’s denominator, so runs - errored is what passed is out of. See Errors Are Not Failures.
Pass rates are printed to the console at the end of the run. This is the number to compare when evaluating one model against another.
Setting the LLM for Evals
Raif defaults to using Raif.config.default_llm_model_key for LLM API calls. You can override this setting via the RAIF_DEFAULT_LLM_MODEL_KEY environment variable.
RAIF_DEFAULT_LLM_MODEL_KEY=anthropic_claude_5_sonnet bundle exec raif evals
Verbose Output
When debugging failing evals or wanting to see more details about your test runs, you can enable verbose output to see metadata and LLM judge reasoning:
# In your initializer
Raif.configure do |config|
config.evals_verbose_output = true
end
Or per run, with --verbose:
bundle exec raif evals --verbose
--no-verbose (or RAIF_EVAL_VERBOSE=0) turns it back off for one run, which is what an app whose initializer sets evals_verbose_output = true needs to read a dataset run: verbose prints every expectation for every case, so a 3-case dataset at --repeat 2 buries the result in judge reasoning. Passing neither flag leaves the configured value alone.
When enabled, this will display:
- Result metadata for each expectation
- LLM judge reasoning and confidence scores
- Every expectation for every dataset case, rather than one line per case
- Additional debugging information
This is particularly useful when working with LLM judges to understand why they made certain decisions.
Captured LLM Calls
Every LLM call made during an eval is captured and included in the results JSON. Because each eval runs in a transaction that is rolled back, these records are captured before the rollback so they’re preserved in the results even though the underlying Raif::ModelCompletion rows are not persisted.
For each eval, the results include:
- A
model_completionsarray with one entry per LLM call. Each entry captures thellm_model_key,model_api_name,system_prompt,messages, the model’sresponse, anyresponse_tool_calls, token counts (prompt_tokens,completion_tokens,total_tokens, and cache token counts), and cost (prompt_token_cost,output_token_cost,total_cost). - A
usageobject summarizing the number of LLM calls, total tokens, and total cost for that eval.
{
"description": "Raif::Tasks::DocumentSummarization produces expected output",
"passed": true,
"expectation_results": [ ... ],
"usage": {
"model_completions": 1,
"prompt_tokens": 1200,
"completion_tokens": 300,
"total_tokens": 1500,
"total_cost": 0.0075
},
"model_completions": [
{
"llm_model_key": "open_ai_gpt_4o",
"model_api_name": "gpt-4o",
"system_prompt": "...",
"messages": [ ... ],
"response": "...",
"prompt_tokens": 1200,
"completion_tokens": 300,
"total_tokens": 1500,
"total_cost": 0.0075
}
]
}
The run’s top-level summary also aggregates totals across every eval: total_model_completions, total_prompt_tokens, total_completion_tokens, total_tokens, and total_cost. These totals are also printed to the console at the end of the run under an LLM Usage heading.
Judge Spend Is Reported Apart
LLM calls made by LLM judges run inside the eval, so they are captured and counted in the totals above alongside the calls made by the code under test. They are also counted again on their own: a result whose eval called a judge gains a judge_usage object of the same shape as usage, and the run’s summary gains total_judge_model_completions, total_judge_tokens, and total_judge_cost. These are a subset of the usage and total_* figures, not spend beside them.
The split exists for comparisons. The judge is meant to be the same model on both sides - evals:compare refuses two different judges - so its spend is not part of what separates two models under test. It does not cancel out either, because a wordier model gives the judge more to read. A single total cost $1.10 -> $1.64 row therefore blends “this model is more expensive” with “this model made the judge work harder”. Both the run summary and evals:compare print the two lines instead:
LLM Usage:
48 LLM calls
$1.104200 total cost
$0.903100 model under test
$0.201100 judge (24 calls)
A result whose eval called no judge has no judge_usage key at all, and a results file written before this was recorded has none either - which is why evals:compare prints - rather than $0.00 for a run whose judge spend is unknown.
Calls your setup or teardown blocks make are treated differently. They are not the eval’s own measurement - a fixture built by an LLM is not what the eval is testing - so they stay out of usage and out of the total_* figures above. But they are real spend, so they are reported rather than dropped: a result whose setup or teardown made any gains an overhead_usage object of the same shape as usage, the run’s summary gains total_overhead_model_completions, total_overhead_tokens, and total_overhead_cost, and the console prints an extra line under LLM Usage. A result whose setup and teardown made no calls - the normal case - has no overhead_usage key at all.
Limiting What Is Captured
Full capture includes every prompt, message, and response, which for a dataset run can produce a results file of tens of megabytes. Set the capture mode in your initializer:
Raif.configure do |config|
# :full (default) - prompts, messages, and responses, plus tokens and cost
# :summary - tokens and cost only
# :none - omit the model_completions array entirely
config.evals_capture_model_completions = :summary
end
The per-eval usage object and the run’s summary totals are the same under all three modes; only the per-call prompt and response text is dropped. The effective mode is recorded in the results configuration block, so a later reader can tell a deliberately trimmed capture from a run that made no LLM calls at all.
Datasets
An eval with one hard-coded input tells you whether your prompt works on that input. --repeat samples the model’s non-determinism, but it re-runs the same input every time, so nothing in the results can distinguish “this model is worse” from “this one input happens to be hard for it”.
A dataset runs the same eval body over many inputs, and reports a pass rate and a score for each one. Declare it with the dataset macro and point an eval at it by name with dataset::
class Raif::Evals::Tasks::DocumentSummarizationEvalSet < Raif::Evals::EvalSet
dataset :documents do
jsonl("documents.jsonl") # raif_evals/datasets/documents.jsonl
end
# setup receives the case, so per-case fixtures are built where fixtures already live
setup do |eval_case|
@user = User.create!(email: "test@example.com")
@document = Document.create!(
title: eval_case.input["title"],
content: file(eval_case.input["file"]),
creator: @user
)
end
eval "produces expected output", dataset: :documents do |eval_case|
task = Raif::Tasks::DocumentSummarization.run(creator: @user, document: @document)
expect "task completes successfully" do
task.completed?
end
expect_includes(task.parsed_response, eval_case.expected["subject"],
label: "mentions the document's main subject")
expect_llm_judge_score(
task.parsed_response,
scoring_rubric: Raif::Evals::ScoringRubric.clarity,
min_passing_score: 4
)
end
end
With raif_evals/datasets/documents.jsonl:
{"id": "climate-report", "input": {"file": "documents/climate_report.html", "title": "2026 Emissions Outlook"}, "expected": {"subject": "emissions"}}
{"id": "earnings-call", "input": {"file": "documents/earnings_call.html", "title": "Q2 Earnings Call"}, "expected": {"subject": "revenue"}}
{"id": "press-release", "input": {"file": "documents/press_release.html", "title": "Product Launch"}, "expected": {"subject": "launch"}}
setup, teardown, and the eval block all accept the case as an optional block argument. Blocks that don’t declare one are called as before, so adding a dataset to an eval set never requires touching its other evals.
A case is an input (i.e. a single row in a dataset), not a run. The eval block is the procedure; the case is what you feed it. Raif runs the block once per case, times the number of repeats.
Two things to know about how the pieces fit together in one file:
- Declare a dataset above the evals that use it.
dataset:is checked when the class body loads, so a name that has not been declared yet raises there rather than silently running zero cases. setupandteardownare shared by every eval in the set, so an eval set that mixes dataset and non-dataset evals hands them anilcase for the non-dataset ones.
Dataset Shape
A dataset is a block that returns an array of cases. The eval will be run against each case in the dataset. Each case is a Hash with up to three keys:
id(required) - identifies the case in the console, the results JSON, and comparisons. Ids must be unique within a dataset. A missing or duplicated id raises when the dataset loads, before any LLM call is made, because a case that can’t be told apart from another can’t be compared against its own past results.input(required) - whatever yoursetupandevalblocks need to build the case.expected(optional) - ground truth to assert against, for cases where you have a known-correct answer. See Ground Truth Matchers for the helpers that compare against it.
Inside your blocks, the case is a Raif::Evals::EvalCase exposing id, input, and expected. [] reads from input, so eval_case["title"] and eval_case.input["title"] are the same thing.
Dataset Sources
jsonl and json read from raif_evals/datasets:
dataset :documents do
jsonl("documents.jsonl") # one JSON case object per line
end
dataset :short_documents do
json("short_documents.json") # a JSON array of case objects
end
files globs raif_evals/files and returns matching paths, relative to that directory, so they compose with the existing file helper. Use it when a case is a whole file:
dataset :corpora do
files("corpora/*.json").map do |path|
{ id: File.basename(path, ".json"), input: JSON.parse(file(path)) }
end
end
The dataset block just has to return an array of case hashes, so a dataset can come from anywhere - a fixture directory, a constant, a query against your own models. Raif imposes no row schema beyond id/input/expected.
Datasets and Repeats
Datasets and --repeat compose: the run is cases × repeats independent runs. Each one re-runs setup with its own case, inside its own database transaction that is rolled back afterwards, so no case can leak state into another.
An exception raised while running one case is recorded as an error for that case only; the remaining cases still run. That covers setup as well as the eval block, so a 20-case dataset does not lose 19 results to one bad fixture.
Selecting Cases to Run
A full dataset run costs real money. These flags restrict which cases run:
# Run only the named cases
bundle exec raif evals --cases climate-report,earnings-call
# Run a random 5 cases from each dataset
bundle exec raif evals --sample 5
# Run the same random 5 cases as a previous --sample run
bundle exec raif evals --sample 5 --seed 42
Each flag has an environment variable equivalent, alongside the existing RAIF_EVAL_REPEATS: RAIF_EVAL_CASES, RAIF_EVAL_SAMPLE, and RAIF_EVAL_SEED.
Sampling without a --seed draws different cases each run, which makes two runs uncomparable case-for-case. The same seed and sample size draw the same cases again. The draw is over the case ids, not over the order the rows are written in, so reordering or reformatting a dataset file leaves the sample alone - the same thing its digest promises. The cases that are drawn still run in dataset order.
A sampled run always ends up with a seed even if you did not pass one: Raif draws one, prints it in the run header, and records it in the results configuration block. So a run you sampled without thinking about seeds can still be repeated case-for-case afterwards, and resuming it picks the sample back up rather than drawing a fresh one and finishing the results file with two unrelated samples in it.
--cases filters every dataset in the run, so an id that belongs to one eval set’s dataset simply skips the others. If it matches nothing anywhere, the run exits non-zero rather than reporting a suite of zero evals that passed.
Dataset Results
Each result in the results JSON carries the case_id that produced it, alongside the existing eval_id, eval_index, and run_index. In summary.eval_pass_rates, an eval with a dataset reports its overall rate across every case and repeat, plus a per_case breakdown:
{
"eval_set": "Raif::Evals::Tasks::DocumentSummarizationEvalSet",
"description": "produces expected output",
"eval_id": "Raif::Evals::Tasks::DocumentSummarizationEvalSet#produces-expected-output-9c1de4a70b2f",
"eval_index": 0,
"cases": 3,
"repeats": 2,
"runs": 6,
"errored": 0,
"passed": 5,
"pass_rate": 0.8333,
"per_case": [
{ "case_id": "climate-report", "runs": 2, "errored": 0, "passed": 2, "pass_rate": 1.0 },
{ "case_id": "earnings-call", "runs": 2, "errored": 0, "passed": 2, "pass_rate": 1.0 },
{ "case_id": "press-release", "runs": 2, "errored": 0, "passed": 1, "pass_rate": 0.5 }
]
}
runs is the number of executions counted in the row it appears on: cases × repeats at the top level, and just repeats within a per_case entry. run_index on an individual result identifies which repeat of its case produced it, so it never exceeds repeats.
The console output stays compact for dataset evals - one line per case per repeat, since a 20-case dataset at --repeat 3 would otherwise print several hundred expectation lines. Failing expectations are still printed under the case that failed them:
Running Raif::Evals::Tasks::DocumentSummarizationEvalSet
--------------------------------------------------
produces expected output
✓ climate-report run 1 3/3 expectations clarity 5
✓ climate-report run 2 3/3 expectations clarity 4
✓ earnings-call run 1 3/3 expectations clarity 4
✓ earnings-call run 2 3/3 expectations clarity 5
✓ press-release run 1 3/3 expectations clarity 4
✗ press-release run 2 2/3 expectations clarity 3
✗ LLM judge score (clarity): >= 4
! quarterly-report run 1 0/1 expectations
✗ Setup execution
The ! in the last line is an eval that raised rather than failed - see Errors Are Not Failures.
A failing expectation’s description is truncated to 100 characters on these lines. An LLM judge expectation is described by its whole criteria, and the same one repeats under every case that failed it, so at full length it buries the case ids and counts the lines exist to show. Pass label: to the judge helpers to choose what appears here; the untruncated text is always in the results JSON, the HTML comparison report, and --verbose output.
Use --verbose (or Raif.config.evals_verbose_output) to get the full per-expectation output for every case. An app that turned verbose output on in its initializer gets the compact output back with --no-verbose, since a dataset at --repeat 2 prints several hundred lines of judge reasoning under verbose.
Adding Result Metadata to Expectations
You can attach metadata to any expect block to capture additional context that will be stored in the results JSON file. This is useful for tracking scores, metrics, or other relevant information alongside pass/fail results.
result_metadata = {
overall_score: task.overall_score,
word_count: summary.length
}
expect "Summary is high quality", result_metadata: result_metadata do
task.overall_score >= 4
end
The metadata will be included in the results JSON:
{
"expectation_results": [
{
"description": "Summary is high quality",
"status": "passed",
"metadata": {
"overall_score": 5,
"word_count": 250
}
}
]
}
Metadata holds context that isn’t a measurement - a judge’s reasoning, a case label, the model’s raw response. It is stored but never aggregated or compared; scores are the mechanism for numbers that are.
Ground Truth Matchers
When a case has a known-correct answer, four matchers compare against it. Each one is an expect block underneath, so it counts toward the eval’s pass rate and matches across runs like any other expectation.
eval "extracts the invoice fields", dataset: :invoices do |eval_case|
task = Raif::Tasks::InvoiceExtraction.run(creator: @user, document: @document)
fields = task.parsed_response
expect_exact_match(fields["vendor"], eval_case.expected["vendor"])
expect_includes(fields["summary"], eval_case.expected["keywords"])
expect_matches(fields["invoice_number"], /\A[A-Z]{2}-\d{4}\z/)
expect_within(fields["total"], eval_case.expected["total"], percent: 1)
end
| Matcher | Passes when |
|---|---|
expect_exact_match(actual, expected) | The two values are equal. Strings are stripped and downcased first; pass strip: false or ignore_case: false to compare them as they are. Non-strings are compared with ==, so false and 42 are not coerced through to_s. |
expect_includes(actual, expected) | Every expected text appears in actual.to_s. expected is a String or an Array of Strings, and an Array requires all of them. Pass ignore_case: false for a case-sensitive search. |
expect_matches(actual, pattern) | actual.to_s matches the pattern. A String pattern is compiled to a Regexp, so a dataset row can carry one. |
expect_within(actual, expected, delta:) | The two numbers differ by no more than the tolerance. Give delta: for an absolute tolerance or percent: for a relative one, and exactly one of the two. A non-numeric actual fails; a non-numeric expected raises, since only your eval put it there. |
Every matcher records what it compared as result metadata, which a hand-written expect block does not:
{
"description": "includes expected text",
"status": "failed",
"metadata": {
"actual": "The company reported strong quarterly results.",
"expected": "[\"revenue\", \"margin\", \"guidance\"]",
"missing": ["margin", "guidance"]
}
}
Values longer than 500 characters are truncated, so a long response does not become most of the results file.
Naming Matcher Expectations
A matcher’s default description names the check and not the value: exact match, includes expected text, matches expected pattern, within 0.5 of expected. That is deliberate. evals:compare tallies an expectation across the cases of an eval by its description, so a description carrying case data would split into one tally per case, and the regression gate would read a rate measured on a single case.
Pass label: when a check deserves a better name, or when one eval uses the same matcher twice:
expect_includes(task.parsed_response, eval_case.expected["subject"], label: "mentions the main subject")
expect_includes(task.parsed_response, eval_case.expected["author"], label: "credits the author")
Keep values that vary per case out of a label:, for the same reason.
Scores
expect answers yes or no. score records a number.
Once two models both clear every pass/fail bar, their results are identical. Scores keep the underlying number, so a drop from 4.6 to 4.1 is visible where “passed” to “passed” is not.
eval "produces a usable summary" do
task = Raif::Tasks::DocumentSummarization.run(creator: @user, document: @document)
# Observational: recorded in the results, never affects pass/fail
score "summary_word_count", task.parsed_response.split.length
# Gated: recorded AND checked, exactly like an expect block
score "clarity", judge.judgment_score, scale: 1..5, min: 4
# For metrics where a smaller number is the better one, gated with a ceiling
score "elapsed_ms", elapsed_ms, max: 5000, higher_is_better: false
# Both bounds, for a metric that can be wrong in either direction
score "bullet_count", task.parsed_response.scan("<li>").length, min: 3, max: 7
end
- Without
min:ormax:, a score is recorded and reported but never fails an eval. Word counts, compression ratios, latency, and cost per call are all scoreable this way. - With
min:and/ormax:, the score also emits a pass/fail expectation named after the comparison it performs (clarity score >= 4,elapsed_ms score <= 5000,bullet_count score >= 3 and <= 7), so gating behaves the same as anexpectblock and the eval’spassed?still means what it always meant. scale:andhigher_is_better:(defaulttrue) are recorded with the value so thatevals:comparecan tell an improvement from a regression. They are independent of the gate:higher_is_bettersays which direction is good,min:/max:say where the eval starts failing.- The name is the metric, and recording the same one twice for a single eval raises. The summary aggregates by name, so two of them would be averaged into one row, where a regression in one can be masked by an improvement in the other. Values drawn from a single response would also be counted as independent samples, which narrows the confidence interval on correlated data. To score several things on one metric, combine the values and record one score.
Each eval result in the results JSON gains a scores array:
"scores": [
{ "name": "clarity", "value": 4.0, "scale": "1..5", "higher_is_better": true, "min": 4, "passed": true },
{ "name": "elapsed_ms", "value": 4210.0, "higher_is_better": false, "max": 5000, "passed": true },
{ "name": "summary_word_count", "value": 284.0, "higher_is_better": true }
]
And the run’s summary gains a score_summaries array, with one row per score name per eval. This is what you compare between two models or two prompts:
{
"eval_set": "Raif::Evals::Tasks::DocumentSummarizationEvalSet",
"description": "produces expected output",
"eval_id": "Raif::Evals::Tasks::DocumentSummarizationEvalSet#produces-expected-output-9c1de4a70b2f",
"eval_index": 0,
"name": "clarity",
"scale": "1..5",
"higher_is_better": true,
"n": 6,
"spread_n": 3,
"mean": 4.33,
"median": 4.5,
"stddev": 0.2887,
"min": 4.0,
"max": 5.0,
"ci95": [4.0, 4.5],
"per_case": [
{ "case_id": "climate-report", "n": 2, "mean": 4.5 },
{ "case_id": "earnings-call", "n": 2, "mean": 4.5 },
{ "case_id": "press-release", "n": 2, "mean": 4.0 }
]
}
min and max here are the lowest and highest values the run actually observed, not the gate. The identically named keys in an individual result’s scores array are the min:/max: bounds passed to score, so the same two names mean the threshold in one place and the range in the other.
per_case is present only for a dataset eval, since without cases there is nothing to break the mean down by. stddev and ci95 are reported alongside the mean because two models a tenth of a point apart with a standard deviation of half a point have not been distinguished.
stddev and ci95 are over cases, not over every observation - which is what spread_n records, and why it is smaller than n in the example above: 3 cases, 6 observations. Pooling all 6 would mix two unrelated things, real differences between the inputs and repeat-to-repeat noise on one input, and on a dataset of any breadth the first dominates. That pooled number describes how varied your dataset is, where what a reader needs beside a mean is how uncertain the mean is. For an eval with no dataset there are no cases, so both are over the individual values and spread_n equals n.
stddev is the sample standard deviation (dividing by n-1). These values are draws from the model’s output distribution rather than the whole of it - that is what --repeat exists to sample - and dividing by n instead understates the spread by 0.71x at 2 values and 0.89x at 5, which is exactly the range these runs live in. Understating it would defeat the only reason it is printed. (This is a partial correction, not a complete one: the square root of an unbiased variance is still biased low. ci95 is the figure to lean on when the difference matters, since it does not depend on that assumption.)
ci95 is a 95% percentile bootstrap confidence interval, resampled from a fixed seed so the same numbers always produce the same interval.
ci95 needs at least 5 values and says so when it does not have them. A bootstrap can only be as informative as the number of values it resamples: 3 values have 10 distinct resamples between them, so the interval restates those three rather than inferring from them, and its real coverage is neither 95% nor stable. Printing one anyway invites exactly the over-reading it was added to prevent. Below 5 the key is replaced with ci95_omitted, naming what it was short of:
"spread_n": 3,
"ci95_omitted": "3 cases; a 95% interval needs 5"
The count is in the unit the spread was measured in - cases for a dataset eval, runs otherwise - since that is what you would have to add more of. The console prints the same fragment beside the mean. Note that this is the same floor the regression gate runs into from the other direction: a handful of cases genuinely cannot support an inference, whichever test is asked for one.
stddev is omitted below 2 values, where the arithmetic returns 0.0 - which in a summary read to decide whether a difference is real would claim a spread had been measured when none was.
Note:
expect_llm_judge_scorerecords a score named after its rubric automatically, in addition to its pass/fail expectation. You get both without writing ascorecall yourself.
LLM-as-Judge Expectations
Raif includes built-in support for using LLMs to evaluate outputs, providing more flexible and nuanced testing than traditional assertions. These “LLM judges” can assess quality, compare outputs, and score responses against rubrics.
Binary Pass/Fail Judgments
Use expect_llm_judge_passes to evaluate whether content meets specific criteria:
eval "produces professional output" do
task = Raif::Tasks::CustomerResponse.run(creator: @user, query: "Fix my broken product!")
expect_llm_judge_passes(
task.parsed_response,
criteria: "Response is polite, professional, and addresses the customer's concern"
)
end
You can provide examples to guide the judge & instruct it to apply criteria strictly:
expect_llm_judge_passes(
output,
criteria: "Contains a proper greeting",
strict: true, # Instruct the judge to apply criteria strictly without leniency
examples: [
{
content: "Hello! How can I help you today?",
passes: true,
reasoning: "Friendly greeting present"
},
{
content: "What do you want?",
passes: false,
reasoning: "No greeting, unprofessional tone"
}
]
)
Scored Evaluations
Use expect_llm_judge_score to evaluate content against a numerical rubric. As well as the pass/fail expectation, the judge’s score is recorded as a score named after the rubric, so it is aggregated into the run summary and can be compared across runs:
eval "produces high-quality technical documentation" do
task = Raif::Tasks::TechnicalWriter.run(creator: @user, topic: "API authentication")
expect_llm_judge_score(
task.parsed_response,
scoring_rubric: Raif::Evals::ScoringRubric.clarity,
min_passing_score: 4
)
end
The score is named after the rubric. Scores are keyed by name, so two clarity scores in one eval raise; score_name: overrides the rubric-derived name when one eval judges two things against the same rubric:
expect_llm_judge_score(bluf, scoring_rubric: rubric, min_passing_score: 4, score_name: "bluf_clarity")
expect_llm_judge_score(findings, scoring_rubric: rubric, min_passing_score: 4, score_name: "findings_clarity")
Built-in Scoring Rubrics
Raif includes several built-in rubrics:
ScoringRubric.accuracy- Evaluates factual correctness (1-5)ScoringRubric.helpfulness- Evaluates how helpful the response is (1-5)ScoringRubric.clarity- Evaluates ease of understanding (1-5)
See the scoring rubric source for details.
Custom Scoring Rubrics
You can also create custom rubrics:
rubric = Raif::Evals::ScoringRubric.new(
name: :technical_depth,
description: "Evaluates technical depth and accuracy",
levels: [
{ score: 5, description: "Expert-level technical detail with perfect accuracy" },
{ score: 4, description: "Strong technical content with minor gaps" },
{ score: 3, description: "Adequate technical coverage" },
{ score: 2, description: "Basic technical content" },
{ score: 1, description: "Minimal technical value" }
]
)
expect_llm_judge_score(
output,
scoring_rubric: rubric,
min_passing_score: 4
)
Or create rubrics with score ranges:
rubric = Raif::Evals::ScoringRubric.new(
name: :code_quality,
description: "Evaluates code quality and best practices",
levels: [
{ score_range: (9..10), description: "Production-ready, follows all best practices" },
{ score_range: (7..8), description: "Good quality, minor improvements possible" },
{ score_range: (5..6), description: "Functional but needs refactoring" },
{ score_range: (3..4), description: "Poor quality, significant issues" },
{ score_range: (0..2), description: "Broken or severely flawed" }
]
)
expect_llm_judge_score(
generated_code,
scoring_rubric: rubric,
min_passing_score: 7
)
Or you can provide the rubric as a string, in which case score_name: is required:
rubric = <<~RUBRIC
- 10 points: Production-ready, follows all best practices
- 8 points: Good quality, minor improvements possible
- 6 points: Functional but needs refactoring
- 4 points: Poor quality, significant issues
- 2 points: Broken or severely flawed
RUBRIC
expect_llm_judge_score(
generated_code,
scoring_rubric: rubric,
min_passing_score: 7,
score_name: "code_quality"
)
A ScoringRubric object names the score it produces via its own name:. A string rubric has no name, and the score name is the metric the run summary aggregates by and evals:compare joins on, so Raif raises rather than recording an unidentifiable metric. The check happens before the judge runs, so it costs nothing to hit.
Comparative Judgments
Use expect_llm_judge_prefers to compare two outputs and verify one is better. The comparative judge automatically randomizes position (A/B) in the prompt to avoid bias and supports tie detection:
eval "new prompt improves over baseline" do
baseline_response = Raif::Tasks::OldSummarizer.run(creator: @user, document: doc).parsed_response
improved_response = Raif::Tasks::NewSummarizer.run(creator: @user, document: doc).parsed_response
expect_llm_judge_prefers(
improved_response,
over: baseline_response,
criteria: "More concise while retaining all key information"
)
end
Or if you want to instruct the judge to pick a winner, you can set allow_ties to false:
eval "new prompt improves over baseline" do
baseline_response = Raif::Tasks::OldSummarizer.run(creator: @user, document: doc).parsed_response
improved_response = Raif::Tasks::NewSummarizer.run(creator: @user, document: doc).parsed_response
expect_llm_judge_prefers(
improved_response,
over: baseline_response,
criteria: "More concise while retaining all key information",
allow_ties: false
)
end
Additional Context
All judge expectations support providing additional context to help with evaluation:
expect_llm_judge_passes(
task.parsed_response,
criteria: "Appropriate for the target audience",
additional_context: "The user is a beginner programmer with no Ruby experience"
)
Adding Result Metadata to Judge Expectations
All LLM judge expectations support adding result metadata that will be merged with the judge’s automatic metadata (scores, reasoning, confidence) in the results JSON. Use the result_metadata parameter:
expect_llm_judge_passes(
response,
criteria: "Response is professional and helpful",
result_metadata: {
test_case_id: "CS-001",
scenario: "customer_complaint",
priority: "high"
}
)
The custom metadata will be combined with the judge’s metadata in the results:
{
"expectation_results": [
{
"description": "LLM judge: Response is professional and helpful",
"status": "passed",
"metadata": {
"test_case_id": "CS-001",
"scenario": "customer_complaint",
"priority": "high",
"passes": true,
"reasoning": "The response demonstrates professionalism...",
"confidence": 0.92
}
}
]
}
Configuring the Judge LLM Model
You can configure the LLM model used for judging in your initializer:
Raif.configure do |config|
# Use a specific model for LLM-as-judge
config.evals_default_llm_judge_model_key = :anthropic_claude_5_sonnet
end
Configure this before you rely on judge scores. With no judge configured, judging falls back to Raif.config.default_llm_model_key - the model being evaluated grades its own output. A model asked to score its own work tends to score it generously (self-preference bias), and it makes the headline use case for these evals actively misleading: running the same suite against a second model with RAIF_DEFAULT_LLM_MODEL_KEY switches the judge along with the subject, so the two runs are scored by two different rulers. A run whose judge is the model under test says so in its header and warns before spending anything:
Raif.config.evals_default_llm_judge_model_key: (not set - judged by open_ai_gpt_5_6_terra, the model under test)
Warning: any LLM judge expectation in this run will be graded by open_ai_gpt_5_6_terra, the model under test.
Two rules of thumb when picking one:
- Hold the judge fixed across everything you intend to compare.
evals:comparerefuses to diff two runs judged by different models for this reason. Each run records the judge it actually used, so two unconfigured runs of two different models are caught as the mismatch they are rather than passing as a comparison. - Prefer a model from outside the family under test, since self-preference extends to a model’s siblings. Judging is short, structured, and cheap relative to the task being judged, so a capable judge from another provider is usually worth it.
Judge calls are LLM calls: they are captured in the results and counted in the run’s cost totals, so a fixed judge also keeps that overhead comparable between runs.
Or you can override the model for a specific judge expectation:
expect_llm_judge_passes(
task.parsed_response,
criteria: "Appropriate for the target audience",
additional_context: "The user is a beginner programmer with no Ruby experience",
llm_judge_model_key: :anthropic_claude_5_sonnet
)
Naming Judge Expectations
A judge expectation’s description is derived from its criteria or rubric, which gets unwieldy when the criteria is a paragraph. Pass label: to name it explicitly:
expect_llm_judge_prefers(
new_summary,
over: baseline_summary,
criteria: "Retains the specific figures, dates, and named entities from the source rather than " \
"paraphrasing them into generalities, while staying within the same length budget",
label: "beats the paraphrasing baseline"
)
The label becomes the expectation’s description, which is also part of how comparisons match a result to its counterpart in an earlier run. Editing a label reads as the old expectation disappearing and a new one arriving.
Judge Task Attributes
Raif::Evals::LlmJudge inherits from Raif::Task. If your app has extended Raif::Task with attributes it requires - a non-nullable tenant or account column, for example - then judge tasks need them too, and without them the built-in judge helpers can’t be used at all: the judge task’s insert fails and takes the surrounding eval’s transaction with it.
Define judge_task_attributes on the eval set to supply them to every judge it runs:
class MyEvalSet < Raif::Evals::EvalSet
setup do
@account = Account.create!(name: "Eval Account")
end
def judge_task_attributes
{ account_id: @account.id }
end
end
It’s called per eval, after setup, so it can reference anything setup created. An individual expectation can add to or override it with judge_attributes::
expect_llm_judge_passes(
task.parsed_response,
criteria: "Response is professional and helpful",
judge_attributes: { account_id: other_account.id }
)
Custom LLM Judges
If you need more control over the judge’s prompting and response handling, you can create custom LLM judges by inheriting from Raif::Evals::LlmJudge. Raif::Evals::LlmJudge inherits from Raif::Task, so you define it like other tasks
You can view an example of a custom judge for judging document summaries here.
class Raif::Evals::LlmJudges::Summarization < Raif::Evals::LlmJudge
# the original content to evaluate the summary against
run_with :original_content
# the summary to evaluate against the original content
run_with :summary
json_response_schema do
object :coverage do
string :justification, description: "Justification for the score"
number :score, description: "Score from 1 to 5", enum: [1, 2, 3, 4, 5]
end
object :accuracy do
string :justification, description: "Justification for the score"
number :score, description: "Score from 1 to 5", enum: [1, 2, 3, 4, 5]
end
object :clarity do
string :justification, description: "Justification for the score"
number :score, description: "Score from 1 to 5", enum: [1, 2, 3, 4, 5]
end
object :conciseness do
string :justification, description: "Justification for the score"
number :score, description: "Score from 1 to 5", enum: [1, 2, 3, 4, 5]
end
object :overall do
string :justification, description: "Justification for the score"
number :score, description: "Score from 1 to 5", enum: [1, 2, 3, 4, 5]
end
end
def build_system_prompt
<<~PROMPT.strip
You are an impartial expert judge of summary quality. You'll be provided an original piece of content and its summary. Your job is to evaluate the summary against the original content based on the following criteria, and assign a score from 1 to 5 for each (5 = excellent, 1 = very poor):
**Coverage (Relevance & Completeness):** Does the summary capture all the important points of the original content?
- 5 = Excellent Coverage - Nearly all key points and essential details from the content are present in the summary, with no major omissions.
- 4 = Good Coverage - Most important points are included, but a minor detail or two might be missing.
- 3 = Fair Coverage - Some main points appear, but the summary misses or glosses over other important information.
- 2 = Poor Coverage - Many critical points from the content are missing; the summary is incomplete.
- 1 = Very Poor - The summary fails to include most of the content's main points (highly incomplete).
**Accuracy (Faithfulness to the Source):** Is the summary factually correct and free of hallucinations or misrepresentations of the content?
- 5 = Fully Accurate - All statements in the summary are correct and directly supported by the content. No errors or invented information.
- 4 = Mostly Accurate - The summary is generally accurate with perhaps one minor error or slight ambiguity, but no significant falsehoods.
- 3 = Some Inaccuracies - Contains a few errors or unsupported claims from the content, but overall captures the gist correctly.
- 2 = Mostly Inaccurate - Multiple statements in the summary are incorrect or not supported by the content.
- 1 = Completely Inaccurate - The summary seriously distorts or contradicts the content; many claims are false or not in the source.
**Clarity and Coherence:** Is the summary well-written and easy to understand? (Consider organization, flow, and whether it would make sense to a reader.)
- 5 = Very Clear & Coherent - The summary is logically organized, flows well, and would be easily understood by the target reader. No confusion or ambiguity.
- 4 = Mostly Clear - Readable and mostly well-structured, though a sentence or transition could be smoother.
- 3 = Somewhat Clear - The summary makes sense overall but might be disjointed or awkward in places, requiring effort to follow.
- 2 = Generally Unclear - Lacks coherence or has poor phrasing that makes it hard to follow the ideas.
- 1 = Very Poor Clarity - The summary is very confusing or poorly structured, making it hard to understand.
**Conciseness:** Is the summary succinct while still informative? (It should omit unnecessary detail but not at the expense of coverage.)
- 5 = Highly Concise - The summary is brief yet covers all important information (no fluff or redundancy).
- 4 = Concise - Generally to-the-point, with only minor redundancy or superfluous content.
- 3 = Moderately Concise - Some excess detail or repetition that could be trimmed, but not egregious.
- 2 = Verbose - Contains a lot of unnecessary detail or repeats points, making it longer than needed.
- 1 = Excessively Verbose - The summary is overly long or wordy, with much content that doesn't add value.
PROMPT
end
def build_prompt
<<~PROMPT.strip
# Instructions
Below is an original piece of content and its summary. Evaluate the summary against the original content based on our 4 criteria. For each, you should provide:
- A brief justification (1-3 sentences) noting any relevant observations (e.g. what was missing, incorrect, unclear, or well-done).
- A score from 1 to 5 (5 = excellent, 1 = very poor).
Finally, provide an **overall evaluation** of the summary, consisting of a brief justification (1-3 sentences) and a score from 1 to 5 (5 = excellent, 1 = very poor).
# Output Format
Format your output as a JSON object with the following keys:
{
"coverage": {
"justification": "...",
"score": 1-5
},
"accuracy": {
"justification": "...",
"score": 1-5
},
"clarity": {
"justification": "...",
"score": 1-5
},
"conciseness": {
"justification": "...",
"score": 1-5
},
"overall": {
"justification": "...",
"score": 1-5
}
}
#{additional_context_prompt}
# Original Article/Document
#{original_content}
# Summary to Evaluate
#{summary}
PROMPT
end
private
def additional_context_prompt
return if additional_context.blank?
<<~PROMPT
\n# Additional context:
#{additional_context}
PROMPT
end
end
Then use it directly in your eval sets for additional flexibility:
eval "Summary meets quality standards" do
doc = Document.create!(content: "Long article content...")
summary_task = Raif::Tasks::Summarizer.run(document: doc)
judge_task = Raif::Evals::LlmJudges::Summarization.run(
original_content: doc.content,
summary: summary_task.parsed_response["summary"]
)
result_metadata = {
score: judge_task.parsed_response["overall"]["score"],
justification: judge_task.parsed_response["overall"]["justification"]
}
expect "Summary is high quality overall", result_metadata: result_metadata do
judge_task.parsed_response["overall"]["score"] >= 4
end
["coverage", "accuracy", "clarity", "conciseness"].each do |score_type|
score = judge_task.parsed_response[score_type]["score"]
justification = judge_task.parsed_response[score_type]["justification"]
result_metadata = {
score: score,
justification: justification
}
expect "#{score_type.capitalize} is >= 4", result_metadata: result_metadata do
score >= 4
end
end
end
This approach gives you control over the judge’s prompting, response schema, and result processing while still integrating with the eval framework.
Expecting Tool Calls
In addition to basic expect blocks, you can use expect_tool_invocation to ensure the LLM invoked a specific tool in its response (or expect_no_tool_invocation to verify it did not).
eval "invokes the WikipediaSearch tool" do
user = User.create!(email: "test@example.com")
conversation = Raif::Conversation.create(
creator: user,
tools: ["Raif::ModelTools::WikipediaSearch"]
)
conversation_entry = conversation.entries.create!(
user_message: "What pages does Wikipedia have about the moon?",
creator: user
)
conversation_entry.process_entry!
expect_tool_invocation(conversation_entry, "Raif::ModelTools::WikipediaSearch", with: { "query" => "moon" })
end
Reading One Run
evals:compare answers what moved between two runs, and needs a baseline to do it. To read a single run, evals:report renders one results file as a self-contained HTML page:
bundle exec raif evals:report \
raif_evals/results/eval_run_20260804_180216_open_ai_responses_gpt_5_4.json
It writes eval_run_20260804_180216_open_ai_responses_gpt_5_4.html beside the results file, or wherever --output points:
# Write somewhere else
--output tmp/last_run.html
# Output format. html is the default and, for now, the only one.
--format html
The page opens with the run’s header - the model, the judge that actually graded, when it started, its shape, its cost, the host app’s commit, and the capture mode - then the run-wide totals, then:
- Failures. Every expectation that did not pass, with the
result_metadatait recorded. This is first because it is what you came for; a full drill-down you have to scroll is not a report. A gated score records both a failed expectation and a score whosepassedis false, and appears here once, through its expectation. - Pass rates, one row per eval, naming the cases that did not pass every run.
- Scores, one row per score name per eval, with the mean, the median, and the spread it was measured over.
- Every eval, as collapsed blocks carrying each execution’s expectations, its scores against their gates, its usage, and its captured LLM calls.
Two things to know before you forward the file. Under the default full capture the results file holds prompts and responses, so the page renders them and its footer says which capture mode produced it - a page of prompts is a different thing to pass around than a page of token counts. Set Raif.config.evals_capture_model_completions to :summary for a report carrying model, token counts, and cost alone.
A run that stopped early writes no results file at all, only its .partial.jsonl log. evals:report recognizes the log and points you at --resume, which finishes the run and writes the file this command reads.
Comparing Runs
Once you have two result files, evals:compare diffs them:
bundle exec raif evals:compare \
raif_evals/results/eval_run_20260804_180216_open_ai_responses_gpt_5_4.json \
raif_evals/results/eval_run_20260805_094122_anthropic_claude_5_sonnet.json
The first file is the baseline and the second is the candidate. The two most common uses are comparing two models on the same prompts, and comparing the same model before and after a prompt change.
Results are matched on eval_id, case_id, and expectation description. Cases are matched individually because a model can improve on average while getting materially worse on one input, which an average alone does not show.
That matching is only as good as the inputs behind it, so the command also checks what each run measured. If the two runs’ dataset fingerprints differ, it warns loudly and continues - comparing a widened dataset against its predecessor is a legitimate thing to do, as long as you know that is what you are looking at:
Warning: these two runs did not measure the same datasets:
documents (SummarizationEvalSet): 24 cases sha256:9f2c... -> 25 cases sha256:1b7e...
Cases are joined by id, so a difference the dataset caused reads as a difference the model caused.
Re-run the baseline against the current dataset to compare the two models alone.
The total cost row is split into the model under test and the judge, since the judge is held fixed across the comparison and its spend is not part of what separates the two models.
Comparing eval runs
baseline open_ai_responses_gpt_5_4 2026-08-04 18:02 2 evals x 3 repeats 4 cases $1.10 eval_run_..._gpt_5_4.json
candidate anthropic_claude_5_sonnet 2026-08-05 09:41 2 evals x 3 repeats 4 cases $1.64 eval_run_..._claude_5_sonnet.json
judge anthropic_claude_5_haiku (both runs)
code 4a91c0b7e2d3 -> 4a91c0b7e2d3
NEW FAILURES (1)
DocumentSummarizationEvalSet produces expected output
press-release 1.00 -> 0.33
1.00 -> 0.33 summary is between 100 and 1000 words
FIXED (1)
DocumentSummarizationEvalSet handles documents that are too short to summarize
stub-document 0.33 -> 1.00
0.33 -> 1.00 returns exactly the text 'Unable to generate summary'
SCORE MOVES (2)
clarity 4.1111 -> 4.5556 +0.4445 (+10.8%, n=18, over 6 cases, sd 0.5137 -> 0.4157)
climate-report 4.3333 -> 5.0 +0.6667
earnings-call 4.0 -> 4.6667 +0.6667
press-release 4.0 -> 4.0 0.0
summary_word_count 284.0 -> 412.0 +128.0 (+45.1%, n=18, over 6 cases, sd 31.2 -> 44.7, not gated)
NOT COMPARABLE (1)
DocumentSummarizationEvalSet produces expected output
quarterly-report candidate only
REGRESSION GATE (1)
DocumentSummarizationEvalSet produces expected output (pass_rate)
67% worse, 0.6667 absolute 1/1 cases worse, p=1.0
SUMMARY
evals passed 16/18 -> 17/18
expectations 70/72 -> 71/72
mean clarity 4.1111 -> 4.5556
mean summary_word_count 284.0 -> 412.0
total cost $1.10 -> $1.64
model under test $0.90 -> $1.40
judge $0.20 -> $0.24
1 regression beyond --fail-on-regression 0.25 (25% worse than baseline), none distinguishable from run-to-run variation at a family-wise 0.05 over 1 candidate row
Options:
# Exit non-zero when a pass rate or a gated score gets more than this much worse than the
# baseline, as a fraction of it: 0.25 means "25% worse". Without it, regressions are still
# reported and the command exits 0.
--fail-on-regression 0.25
# Family-wise significance level a regression must clear as well as the size threshold
# (default 0.05). 1 waives the requirement and gates on the point estimate alone.
--significance 0.05
# text (default), json, or html. html writes a self-contained file next to the results.
--format html
# Compare runs that used different judge models anyway (see below)
--allow-judge-mismatch
# Fraction of runs either side may lose to errors before --fail-on-regression declines to
# decide (default 0.05). 1 gates on the surviving runs regardless.
--max-error-rate 0.05
Some specific behaviors:
- Cases present in only one run are reported under NOT COMPARABLE, never dropped. A silently omitted case is indistinguishable from agreement. An expectation that exists on only one side is reported the same way, which is what a renamed description looks like.
- Errors are reported apart from failures. Runs that raised are excluded from the pass rates rather than counted as misses, so a flaky provider does not read as a quality regression. What each side lost to errors is reported under ERROR RATES and in the SUMMARY, and a case that errored on every run of a side is reported under NOT COMPARABLE - it measured nothing, which is the same problem as a case only one run has. See Errors Are Not Failures.
- Runs judged by different models are refused. Scores from two different judge models measure two different things, so the command exits 2 without printing a comparison. This compares the judge each run actually used, so it also catches the case where neither run configured a judge and each was therefore graded by its own model under test.
--allow-judge-mismatchoverrides this and labels the output accordingly. - Score direction is honored. A score declared
higher_is_better: falsecounts a decrease as an improvement, and ungated observational scores are reported but never trip--fail-on-regression. - The threshold is relative to the baseline, not absolute. A pass rate, a 1-5 rubric score, and a latency in milliseconds are not in the same units, so one absolute threshold cannot mean the same thing to all three:
0.25would ask for a quarter of an eval’s runs on one row and a quarter of a millisecond on the next, which makes the flag fire on noise until you turn it off. Each regression is divided by what it started from instead, so--fail-on-regression 0.25asks one question everywhere - did anything get more than 25% worse. Aclaritymean of 4.0 dropping to 3.6 is0.1; anelapsed_msmean of 1000 rising to 1200 is0.2. Score moves print their relative change next to the absolute one so you can see which rows are near the threshold. For a pass rate that started at 1.0 - an eval that used to pass every run - the relative and absolute readings are the same number, so the common case reads exactly as you would expect. - A regression from a baseline of zero has no fraction to take, and trips any threshold. A gated
error_countgoing from 0 to 3 is a real regression that cannot be expressed as a percentage of zero, so it is reported with a null magnitude and always fails the gate rather than being skipped for want of a denominator. - A case that traded one failure for another is a regression, even though its pass rate did not drop. One failure traded for another is not a fix. A case whose rate held steady is reported under NEW FAILURES; a case that fixed more than it broke reads better under FIXED, but either way the expectation that dropped is listed beneath it and counts toward
--fail-on-regression, so an improvement cannot hide the loss underneath it. - No Rails boot. The command reads two JSON files and does arithmetic, so it does not load your application, need a database, or need an API key.
What the Gate Requires
--fail-on-regression asks two questions, and a row has to answer both before the command exits 1:
- Is it big enough? The relative threshold above.
- Is it consistent enough to tell apart from run-to-run variation? LLM output varies between runs, so a threshold on point estimates alone fires on noise - and a gate that fires on noise gets disabled, which is worse than having no gate.
The second question is answered from the pairing the two runs already have. The same dataset cases ran on both sides, so each case is a matched pair, and the gate asks how surprising it would be for this many pairs to move the same way if the two runs were interchangeable. That is a sign test, computed exactly. Cases that did not move are excluded rather than split - a case that scored the same on both runs is evidence for neither.
The report shows both halves, so a withheld verdict is legible rather than mysterious:
REGRESSION GATE (2)
DocumentSummarizationEvalSet produces expected output (pass_rate)
100% worse, 1.0 absolute 8/8 cases worse, p=0.007813
clarity (score)
40% worse, 2.0 absolute 8/8 cases worse, p=0.007813
Some consequences worth knowing before you wire this into CI:
- How many cases you need. The sign test’s smallest reachable p-value is set by the number of pairs, not by how large the regression is: 5 cases all moving the same way is
p = 0.0625, 6 is0.03125, 8 is0.0078. Below about 6 cases no regression can clear the default 0.05 - not because the tooling is being cautious, but because 5 matched pairs genuinely cannot distinguish a consistent move from a coin flip. Widen the dataset, or lower the bar deliberately with--significance. --repeatdoes not create pairs. Repeats sharpen each case’s own estimate, which makes its direction more reliable; cases are what the test counts. A run with many repeats and one case still has one pair.- The level is divided by the number of rows tested. The gate fails if any row regresses, so testing 20 rows at 0.05 each would fail one run in three on noise alone. Each candidate row is tested at
0.05 / (number of candidate rows)instead (Bonferroni), so0.05means what it says about the run as a whole. Only rows that already cleared the size threshold are counted, which keeps the correction as loose as it can honestly be. - Evals with no dataset are handled differently. A non-dataset eval has no matched unit, so its pass rate goes to a Fisher exact test on the repeat counts on each side. That works - 5 of 5 passing against 0 of 5 is
p = 0.0079- but it needs several repeats: at--repeat 1it returnsp = 1.0, which is the right answer to “one draw against one draw”. - A score on a non-dataset eval cannot be tested at all, and the command says so rather than exiting 0 on it. There is no exact two-sample test for a continuous score at these counts, and approximating one would invent precision the data does not have. If every regression in a run is untestable,
evals:compareexits 2 - refusing to decide, the same way it refuses two mismatched judges - rather than reporting a run that may well have regressed as clean. Give those evals a dataset, or pass--significance 1. - A run that lost too much to errors is not gated at all. Excluding errored runs from the pass rates fixes the first-order problem, but not the second: if the runs that errored were not a random sample of the ones that did not - the long inputs are the ones that time out - the surviving denominator is a biased one. Past
--max-error-rate(default0.05) on either side,evals:compareprints the comparison and then exits 2 rather than passing or failing, the same way it refuses two mismatched judges. Re-run the affected arm, or pass--max-error-rate 1to gate on the surviving runs anyway. --significance 1restores gating on effect size alone. Use it when you know the sample is too small for a verdict and you want the point estimate to gate anyway. It is the honest way to have the old behavior, and the report labels it:evidence not required (--significance 1.0).
Pass rate rows are gated per eval rather than per case, since the cases are what the evidence is drawn from. Per-case detail is still reported under NEW FAILURES, where a single case that got worse is visible without being able to fail the build on its own.