llm-evals 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +26 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +764 -0
  5. data/exe/evals +9 -0
  6. data/exe/llm-evals +14 -0
  7. data/lib/llm/evals/adapter.rb +47 -0
  8. data/lib/llm/evals/case.rb +36 -0
  9. data/lib/llm/evals/cli/cases.rb +48 -0
  10. data/lib/llm/evals/cli/command.rb +163 -0
  11. data/lib/llm/evals/cli/compare.rb +40 -0
  12. data/lib/llm/evals/cli/formatting.rb +16 -0
  13. data/lib/llm/evals/cli/leaderboard.rb +183 -0
  14. data/lib/llm/evals/cli/model_colors.rb +23 -0
  15. data/lib/llm/evals/cli/model_command.rb +437 -0
  16. data/lib/llm/evals/cli/progress_bars.rb +147 -0
  17. data/lib/llm/evals/cli/render.rb +37 -0
  18. data/lib/llm/evals/cli/run.rb +69 -0
  19. data/lib/llm/evals/cli/standings.rb +114 -0
  20. data/lib/llm/evals/cli/verify.rb +24 -0
  21. data/lib/llm/evals/cli/version.rb +15 -0
  22. data/lib/llm/evals/cli.rb +43 -0
  23. data/lib/llm/evals/completion.rb +11 -0
  24. data/lib/llm/evals/config.rb +198 -0
  25. data/lib/llm/evals/configuration.rb +173 -0
  26. data/lib/llm/evals/contender.rb +31 -0
  27. data/lib/llm/evals/discovery.rb +95 -0
  28. data/lib/llm/evals/experiment.rb +158 -0
  29. data/lib/llm/evals/helper.rb +98 -0
  30. data/lib/llm/evals/launcher.rb +37 -0
  31. data/lib/llm/evals/metric.rb +26 -0
  32. data/lib/llm/evals/model.rb +55 -0
  33. data/lib/llm/evals/providers/base.rb +93 -0
  34. data/lib/llm/evals/providers/catalog.rb +32 -0
  35. data/lib/llm/evals/providers/open_router.rb +64 -0
  36. data/lib/llm/evals/providers/registry.rb +112 -0
  37. data/lib/llm/evals/providers/type_safe.rb +53 -0
  38. data/lib/llm/evals/render/reports/html.rb +266 -0
  39. data/lib/llm/evals/render/reports/json.rb +172 -0
  40. data/lib/llm/evals/render/reports/pipeline.rb +153 -0
  41. data/lib/llm/evals/render/reports/terminal.rb +46 -0
  42. data/lib/llm/evals/reply.rb +18 -0
  43. data/lib/llm/evals/run_result.rb +14 -0
  44. data/lib/llm/evals/runtime.rb +74 -0
  45. data/lib/llm/evals/schema_validator.rb +84 -0
  46. data/lib/llm/evals/score.rb +26 -0
  47. data/lib/llm/evals/scorer.rb +45 -0
  48. data/lib/llm/evals/templates/html_report.css +206 -0
  49. data/lib/llm/evals/templates/html_report.html.erb +184 -0
  50. data/lib/llm/evals/templates/html_report.js +48 -0
  51. data/lib/llm/evals/testing/echo_adapter.rb +29 -0
  52. data/lib/llm/evals/testing/fake_model.rb +18 -0
  53. data/lib/llm/evals/testing/fake_provider.rb +67 -0
  54. data/lib/llm/evals/tracker.rb +80 -0
  55. data/lib/llm/evals/trackers/braintrust.rb +91 -0
  56. data/lib/llm/evals/trackers/local.rb +78 -0
  57. data/lib/llm/evals/trackers/logfire.rb +143 -0
  58. data/lib/llm/evals/trackers/registry.rb +82 -0
  59. data/lib/llm/evals/usage.rb +33 -0
  60. data/lib/llm/evals/verify.rb +49 -0
  61. data/lib/llm/evals/version.rb +7 -0
  62. data/lib/llm/evals/warnings.rb +98 -0
  63. data/lib/llm/evals/weighted_score.rb +116 -0
  64. data/lib/llm/evals.rb +83 -0
  65. metadata +274 -0
data/README.md ADDED
@@ -0,0 +1,764 @@
1
+ # LLM Evals:
2
+
3
+ ## A Flexible CLI Framework for running multi-dataset, multi-case evaluation suites.
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/llm-evals.svg)](https://rubygems.org/gems/llm-evals) [![CircleCI](https://dl.circleci.com/status-badge/img/gh/kigster/llm-evals/tree/main.svg?style=shield)](https://dl.circleci.com/status-badge/redirect/gh/kigster/llm-evals/tree/main) [![Ruby](https://github.com/kigster/llm-evals/actions/workflows/main.yml/badge.svg)](https://github.com/kigster/llm-evals/actions/workflows/main.yml) ![Coverage](docs/badges/coverage-badge.svg)
6
+
7
+ A flexible CLI for running multi-case evaluation suites and comparing LLMs and prompt configurations.
8
+
9
+ > [!IMPORTANT]
10
+ > This gem is not affiliated with the excellent [RubyLLM](https://rubyllm.com/) gem, though it uses RubyLLM under the hood.
11
+
12
+ > [!NOTE]
13
+ > The `llm-evals` gem installs two commands: `evals` and `llm-evals`. Use `llm-evals` if another installed package already provides the `evals` command. The CLI and runner use [Dry.rb](https://dry-rb.org/), and the default model connection uses OpenRouter.
14
+ >
15
+ > The gem also supports OpenTelemetry and can send evaluation data to services such as [Braintrust](https://braintrust.ai) and [W&B Weave](https://wandb.ai/site/weave/). Some design ideas came from [Pydantic Evals](https://pydantic.dev/logfire/evals).
16
+ >
17
+ > I originally developed the gem for [Qualified.At](https://qualified.at), a Ruby on Rails SaaS product.
18
+
19
+ ## Evals are unit tests for probabilistic software
20
+
21
+ An eval checks whether a model meets the behavior you expect on a set of representative cases. Repeating the same cases lets you measure how prompts, parameters, and models affect the results. Your scorer decides whether those results are good; the eval makes the comparison consistent.
22
+
23
+ ### References
24
+
25
+ - [AI Evals: Everything You Need to Know](https://hamel.dev/blog/posts/evals-faq/) (Hamel Husain)
26
+ - [Measure Against Quality Baselines](https://www.braintrust.dev/product/evaluate) (Braintrust)
27
+ - [Demystifying Evals for AI Agents](https://www.anthropic.com/research/demystifying-evals-for-ai-agents) (Anthropic)
28
+ - [Eval-Driven System Design: From Prototype to Production](https://developers.openai.com/cookbook/examples/partners/eval_driven_system_design/receipt_inspection) (OpenAI)
29
+ - [Independent Eval Research](https://www.braintrust.dev/evals) (Braintrust)
30
+ - [How to Build an LLM Eval Pipeline in GitHub Actions](https://www.braintrust.dev/articles/llm-eval-pipeline-github-actions) (Braintrust)
31
+
32
+ ### A real-world example: Qualified.At
33
+
34
+ > [!NOTE]
35
+ > If you already know what evals are, feel free to skip this section.
36
+
37
+ This gem grew out of work on [Qualified.At](https://qualified.at), a lead-qualification and job-cost-estimation service. It uses the [Inquirex](https://github.com/inquirex) Ruby gems to define branching forms independently of how they are displayed. The same form can run in a terminal with [inquirex-tty](https://github.com/inquirex/inquirex-tty), or appear as a web form through the [inquirex-webui](https://www.npmjs.com/package/inquirex-webui) and [inquirex-widget](https://www.npmjs.com/package/inquirex-widget) packages.
38
+
39
+ On Qualified.At, a business can build an intake form in a visual editor or with an AI assistant, publish it, and add it to its website with a few lines of JavaScript. A visitor can open the form and answer questions before speaking with anyone. The forms can also start with a free-text description. An LLM extracts structured answers from that description, which can shrink a 20- or 30-question form to just a few follow-up questions.
40
+
41
+ You can [explore the ten demos](https://qualified.at/demos). The businesses are fictional, but the forms are live. A completed submission is saved to our database.
42
+
43
+ For example, someone visiting an [injury law firm](https://qualified.at/demos/personal-injury) or a [tax preparation firm](https://qualified.at/demos/tax-preparation) might want basic answers before calling. Personally, I like being able to answer a few questions and get an estimate without talking to anyone yet.
44
+
45
+ A tax customer might write: *"I'm married with three children, filing jointly, have three side businesses (two LLCs and an S-Corp), a rental property, a house I live in, a yacht my business bought for me, and a McLaren for the commute."* We can smile at the last part, but the useful point is that one paragraph may answer more than ten questions. The form can ask only for what's still missing.
46
+
47
+ ### Why evals matter here
48
+
49
+ When we built Qualified.At, it wasn't clear which provider or model would extract answers most accurately from unstructured text and map them to a JSON schema. Accuracy matters, but so do latency and cost. At 1,000 leads, a cost of $0.0002 per call adds up to $0.20; a cost of $0.50 per call adds up to $500.
50
+
51
+ > [!IMPORTANT]
52
+ > Which model captures the most answers, assigns them correctly, avoids hallucinations, and doesn't miss details? We want accuracy with low cost and low latency. If a user waits 30 seconds to get to the next question, that's a problem.
53
+
54
+ That question is why I built this gem. The report below compares models on accuracy, completeness, latency, cost, and other metrics for this extraction task.
55
+
56
+ ![LLM evaluation HTML report](docs/img/llm-evals-html-report.avif)
57
+
58
+ > [!NOTE]
59
+ > *The screenshot is a little out of date. I later reversed the model order so the best scores appear at the top. In this run, Anthropic's `claude-haiku-5.5` was the best fit for my priorities: speed, cost, and accuracy.*
60
+
61
+ I had used evals before and wanted to understand the ideas better and build something reusable for the Ruby community. Since this is a CLI tool, I use Dry.rb utilities such as `dry-cli`. I also maintain three extensions at [dry-cli.tools](https://dry-cli.tools).
62
+
63
+ ## Datasets
64
+
65
+ Organize each use case as a dataset with multiple cases. The [cases-on-disk section](#cases-on-disk) shows the directory layout and how to add variants.
66
+
67
+ The gem runs every model in your lineup over every case in your folder, in parallel, and prints a leaderboard with scores, tokens and dollars.
68
+
69
+ You write two small classes: an **adapter** that asks the model, and a **scorer** that grades the answer.
70
+
71
+ The gem handles the rest:
72
+
73
+ - case discovery,
74
+ - the model lineup,
75
+ - providers,
76
+ - threads,
77
+ - progress bars,
78
+ - the leaderboard,
79
+ - and optional result reporting to Braintrust or Logfire.
80
+
81
+ ### Why
82
+
83
+ Every LLM project grows its own eval script, and every one of them rebuilds the same runner, model list, thread pool and results table. This gem is that script, written once.
84
+
85
+ - **Scores reflect production.** Your adapter calls your real prompt builder and your real parser. The gem never touches what the model sees.
86
+ - **Compare settings, not just models.** `effort: [low, high]` and `temperature: [0.0, 0.7]` expand into one contender per combination.
87
+ - **Know the cheap winner.** "Best value" is the cheapest experiment within two points of the top score.
88
+ - **Failures are counted honestly.** A wrong answer scores 0. A network error is an error, not a 0. A model your suite cannot ask is left out by name, not scored.
89
+ - **No lock-in.** A local JSON log is always written. Braintrust, Logfire or your own tracker sits beside it.
90
+
91
+ ## What you get
92
+
93
+ The bundled toy suite, run against two fake models (no network):
94
+
95
+ ```text
96
+ $ evals compare --local
97
+ ┌─────────┬─────────────┬─────────┬────────────────┬─────────────┬───────────┬────────┬─────────┐
98
+ │ dataset │ model │ effort │ field_accuracy │ exact_match │ restraint │ errors │ cost │
99
+ ├─────────┼─────────────┼─────────┼────────────────┼─────────────┼───────────┼────────┼─────────┤
100
+ │ animals │ fake/echo │ default │ 100.0% │ 100.0% │ 100.0% │ 0 │ $0.0040 │
101
+ │ animals │ fake/echo │ low │ 100.0% │ 100.0% │ 100.0% │ 0 │ $0.0040 │
102
+ │ animals │ fake/parrot │ default │ 100.0% │ 100.0% │ 100.0% │ 0 │ $0.0040 │
103
+ │ colors │ fake/echo │ default │ 100.0% │ 100.0% │ 100.0% │ 0 │ $0.0040 │
104
+ │ colors │ fake/echo │ low │ 100.0% │ 100.0% │ 100.0% │ 0 │ $0.0040 │
105
+ │ colors │ fake/parrot │ default │ 100.0% │ 100.0% │ 100.0% │ 0 │ $0.0040 │
106
+ └─────────┴─────────────┴─────────┴────────────────┴─────────────┴───────────┴────────┴─────────┘
107
+ ```
108
+
109
+ This table is abridged. The full table also includes temperature, runtime, and input and output token counts. The report adds a glossary, a best-value summary for each dataset, and a leaderboard for each case variant. The model name appears last in each row and uses the same color as its progress bar. Use `--scores` to print every experiment ranked by score, with a link to its tracker. The report includes one column for each metric declared by your scorer.
110
+
111
+ ## Run the examples
112
+
113
+ The repository ships two example suites. Both use fake models, so they need no API keys and no network. They live under `spec/fixtures/` and are **not** part of the installed gem, so start from a clone:
114
+
115
+ ```bash
116
+ git clone https://github.com/kigster/llm-evals.git
117
+ cd llm-evals
118
+ bundle install
119
+ ```
120
+
121
+ ### The quick way: `just`
122
+
123
+ Run these commands from the repository root with [`just`](https://github.com/casey/just) installed (`brew install just`):
124
+
125
+ ```bash
126
+ # Example 1: cases, verify, then compare --local
127
+ just toy
128
+
129
+ # Example 2: the same, for the config-only suite
130
+ just config-only
131
+
132
+ # Both
133
+ just examples
134
+
135
+ # Any other command, run inside the suite's folder
136
+ just toy compare -n
137
+ just toy run -m fake/echo -d animals --local
138
+ just config-only cases
139
+ ```
140
+
141
+ The recipes change into each suite's folder and set `RUBYLIB` for the config-only suite. The following commands show how to run them by hand.
142
+
143
+ ### Where to run from
144
+
145
+ `llm-evals` finds a suite by searching **upward from the current directory** for `evals/eval_helper.rb` or `evals/.llm-evals.config.yml`. The repository root has neither, so running there fails:
146
+
147
+ ```text
148
+ $ bundle exec exe/evals cases
149
+ error: no suite is configured: register an adapter and a scorer in evals/eval_helper.rb, ...
150
+ ```
151
+
152
+ Run the command from a suite's folder (the parent of its `evals/` directory), or pass the helper path with `--helper`.
153
+
154
+ ### The toy suite (`eval_helper.rb` style)
155
+
156
+ If you use `just` then the easiest way to run the "toy" suite is with `just toy`.
157
+
158
+ But it helps to understand what's happening under the hood.
159
+
160
+ ```bash
161
+ cd spec/fixtures/toy_suite
162
+
163
+ # List the 8 cases (2 datasets × 2 variants × 2 cases); calls nothing.
164
+ bundle exec evals cases
165
+
166
+ # Offline checks of inputs, expected.json, the adapter and the scorer.
167
+ bundle exec evals verify
168
+
169
+ # Show the experiment matrix the lineup expands into; calls nothing.
170
+ bundle exec evals compare -n
171
+
172
+ # One model, one dataset, with the per-case breakdown.
173
+ bundle exec evals run -m fake/echo -d animals --local
174
+
175
+ # Every model in the lineup, leaderboard only.
176
+ bundle exec evals compare --local
177
+ ```
178
+
179
+ You can also run the command from the repository root and pass the helper path with `--helper`:
180
+
181
+ ```bash
182
+ bundle exec exe/llm-evals compare --local --helper spec/fixtures/toy_suite/evals/eval_helper.rb
183
+ ```
184
+
185
+ Files to look at:
186
+
187
+ | File (under `spec/fixtures/toy_suite/`) | What it shows |
188
+ | --------------------------------------- | ---------------------------------------------------------- |
189
+ | `evals/eval_helper.rb` | `LLM::Evals.configure`: suite, cases glob, adapter, scorer |
190
+ | `lib/toy_suite/evals.rb` | the adapter and scorer classes |
191
+ | `evals/.llm-evals.config.yml` | the lineup, including a `direct:` entry the suite refuses |
192
+ | `evals/datasets/` | the case layout on disk |
193
+
194
+ The warning `typesafe/jev-latest refused: needs typesafe; this suite produces prompt` is intentional: it shows a contender being left out rather than scored 0.
195
+
196
+ ### The config-only suite (no `eval_helper.rb`)
197
+
198
+ This suite names its adapter and scorer in `.llm-evals.config.yml` instead of registering them in Ruby. Its `require: toy_suite/evals` must be loadable, which in a real project means a gem in your bundle. Here the code lives in the toy suite's `lib/`, so add it to the load path with `RUBYLIB`:
199
+
200
+ ```bash
201
+ cd spec/fixtures/config_only_suite
202
+ RUBYLIB=../toy_suite/lib bundle exec evals cases
203
+ RUBYLIB=../toy_suite/lib bundle exec evals compare --local
204
+ ```
205
+
206
+ Without `RUBYLIB` the run stops with `require: toy_suite/evals could not be loaded ... is its gem in the bundle?`.
207
+
208
+ ### Against real models
209
+
210
+ Use the toy suite as a template:
211
+
212
+ 1. Copy its `evals/` folder into your project.
213
+ 1. Replace the adapter and scorer with your own (see [Writing a suite](#writing-a-suite)).
214
+ 1. List real models under `vendors:` in `.llm-evals.config.yml` and drop the `fake:` entries.
215
+ 1. Add `gem "ruby_llm"` to your Gemfile and set `OPENROUTER_API_KEY`, in your shell or the suite's `.env` (see [Environment](#environment)).
216
+
217
+ ## Install
218
+
219
+ Install the CLI directly:
220
+
221
+ ```bash
222
+ gem install llm-evals
223
+ ```
224
+
225
+ Or add the gem to your Gemfile:
226
+
227
+ ```ruby
228
+ gem "llm-evals"
229
+
230
+ # for OpenRouter, the default provider
231
+ gem "ruby_llm"
232
+
233
+ # only for the Braintrust tracker
234
+ gem "braintrust"
235
+
236
+ # only for the Logfire tracker
237
+ gem "opentelemetry-sdk"
238
+ gem "opentelemetry-exporter-otlp"
239
+ ```
240
+
241
+ The gem requires Ruby 3.4 or later. `ruby_llm`, `braintrust`, and the OpenTelemetry gems are optional. They load only when a command needs them; if one is missing, the error names it.
242
+
243
+ ## Writing a suite
244
+
245
+ The boundary runs one way:
246
+
247
+ - The **adapter** runs one case against a model and returns a `Reply`. It owns everything the model sees and everything done to its answer. It never scores.
248
+ - The **scorer** turns a case and a reply into a `Score`. It is pure and deterministic. It never calls anything.
249
+
250
+ Nothing in `lib/` knows what is being evaluated.
251
+
252
+ The executable looks for `evals/eval_helper.rb` upward from the current directory (or `--helper PATH`, or `LLM_EVALS_HELPER`), loads it, and dispatches. The helper calls `LLM::Evals.configure`:
253
+
254
+ ```ruby
255
+ # evals/eval_helper.rb
256
+ require "bundler/setup"
257
+ require "llm/evals"
258
+ require "my_gem"
259
+
260
+ class MyAdapter < LLM::Evals::Adapter
261
+ # Once per dataset, outside the threads.
262
+ def prepare(dataset) = @schema = MyGem.schema_for(dataset)
263
+
264
+ def call(kase, model:)
265
+ completion = model.complete(
266
+ system: MyGem.system_prompt(@schema), user: kase.input, json: true)
267
+
268
+ # The production parser, so scores reflect production.
269
+ output = MyGem.parse(completion.text)
270
+ LLM::Evals::Reply.new(output: output, raw: completion.text, usage: completion.usage)
271
+ rescue MyGem::NotJSON => e
272
+ # The model's failure: scored 0.
273
+ LLM::Evals::Reply.new(error: e.message, raw: completion&.text)
274
+ end
275
+
276
+ # What the scorer needs beyond the case.
277
+ def context(kase) = { schema: @schema }
278
+
279
+ # Offline, for `llm-evals verify`.
280
+ def verify(dataset) = MyGem.schema_problems(dataset)
281
+ end
282
+
283
+ class MyScorer < LLM::Evals::Scorer
284
+ def metrics
285
+ [
286
+ LLM::Evals::Metric.new(
287
+ name: "field_accuracy",
288
+ description: "Mean per-field score.",
289
+ primary: true
290
+ ),
291
+ LLM::Evals::Metric.new(
292
+ name: "exact_match",
293
+ description: "1 when every field is right."
294
+ ),
295
+ LLM::Evals::Metric.new(
296
+ name: "restraint",
297
+ description: "Of the fields the text never answers, the share left null."
298
+ )
299
+ ]
300
+ end
301
+
302
+ def call(kase, reply, context:)
303
+ scores = MyGem.field_scores(context[:schema], kase.expected, reply.output)
304
+ mean = scores.values.sum / scores.size
305
+ misses = scores.reject { |_, v| v == 1.0 }.keys
306
+
307
+ LLM::Evals::Score.new(
308
+ value: mean,
309
+ metrics: {
310
+ "field_accuracy" => mean,
311
+ "exact_match" => scores.values.all?(1.0) ? 1.0 : 0.0,
312
+ # nil: this case does not apply; it is skipped, not zeroed.
313
+ "restraint" => nil
314
+ },
315
+ diagnostics: misses.to_h do |f|
316
+ [f, { want: kase.expected[f], got: reply.output[f] }]
317
+ end
318
+ )
319
+ end
320
+
321
+ def verify(kase, context:)
322
+ LLM::Evals::SchemaValidator.new(context[:schema]).problems(kase.expected)
323
+ end
324
+
325
+ def summary(kase) = "#{kase.expected.count { |_, v| !v.nil? }}/#{kase.expected.size} answered"
326
+ end
327
+
328
+ LLM::Evals.configure do |c|
329
+ c.suite "extract", root: __dir__, env_files: ["../.env"]
330
+ c.cases "datasets/*/cases/**/input.txt"
331
+ c.adapter MyAdapter.new
332
+ c.scorer MyScorer.new
333
+ # After the env files load, before any model is called.
334
+ c.before_run { MyGem.reset_configuration! }
335
+ end
336
+ ```
337
+
338
+ A complete, runnable suite lives in [`spec/fixtures/toy_suite/evals/`](spec/fixtures/toy_suite/evals/): two datasets, two variants, the gem's own `Testing::EchoAdapter` and a fake provider. It runs without a network.
339
+
340
+ ### A config-only suite
341
+
342
+ If your adapter and scorer live in your own gem, you can omit `eval_helper.rb` and name them in `.llm-evals.config.yml`:
343
+
344
+ ```yaml
345
+ # evals/.llm-evals.config.yml
346
+ require: my_gem/evals # a require path, or a list; loaded through the bundle's load path
347
+ adapter: MyGem::Evals::Adapter # constant names, instantiated with no arguments
348
+ scorer: MyGem::Evals::Scorer
349
+ ```
350
+
351
+ The executable searches upward from the working directory for `evals/.llm-evals.config.yml`, sets `evals/` as the suite root, loads the files listed under `require:`, and instantiates the two constants. `evals/` holds only the config file and `datasets/`:
352
+
353
+ ```text
354
+ lib/my_gem/evals/adapter.rb # the Ruby, in the gem
355
+ lib/my_gem/evals/scorer.rb
356
+ evals/.llm-evals.config.yml # the lineup, plus require/adapter/scorer
357
+ evals/datasets/...
358
+ ```
359
+
360
+ If `evals/eval_helper.rb` also exists, it loads first and may register either object in code; the config keys fill whatever it left unset. The required file may itself call `LLM::Evals.configure`, for instance to register a provider.
361
+
362
+ Errors identify the problem. If neither source provides an adapter or scorer, the error names the file and missing key. A failed `require:` or unknown constant names the key and value instead of showing only a `LoadError` or `NameError`. [`spec/fixtures/config_only_suite/`](spec/fixtures/config_only_suite/) is the toy suite in this form.
363
+
364
+ ### Cases on disk
365
+
366
+ ```text
367
+ evals/
368
+ eval_helper.rb
369
+ .llm-evals.config.yml # the lineup and the request types
370
+ datasets/<dataset>/cases/<variant>/[NNN/]input.txt
371
+ datasets/<dataset>/cases/<variant>/[NNN/]expected.json
372
+ datasets/<dataset>/cases/<variant>/[NNN/]metadata.json # optional; merged into Case#metadata
373
+ ```
374
+
375
+ - The folder after `datasets/` is the **dataset**.
376
+ - The folder holding `input.txt` is the **variant**, unless it is a number: then it is the case number and its parent is the variant.
377
+ - A case id is `variant/NNN`. `-c clear` selects a variant, `-c clear/003` one case.
378
+
379
+ Need another layout? Change the glob passed to `c.cases`.
380
+
381
+ ### The lineup: `.llm-evals.config.yml`
382
+
383
+ The file at the suite root names the models to compare, their settings, and the kinds of request the suite sends:
384
+
385
+ ```yaml
386
+ # evals/.llm-evals.config.yml
387
+ project: my-evals # Env BRAINTRUST_PROJECT_ID takes precedence,
388
+ # but this is used as Braintrust project name
389
+
390
+ request_types: [prompt] # required: what the suite's adapter sends
391
+
392
+ defaults:
393
+ effort: default # default sends none and leaves the model's own setting
394
+ temperature: 0.1 # lower temp usually produce less variation (0 < t < 1)
395
+ max_tokens: 8192 # covers the reasoning tokens a high effort spends
396
+ parallelism: 4 # cases in flight per experiment; the default for -p
397
+
398
+ results:
399
+ autosave: true # save every completed run as JSON (default)
400
+ destination: ./evals/results/
401
+ temp_dir: ./tmp # temporary JSON when autosave is false
402
+ display: browser # none, tui or browser
403
+
404
+ # This section requires OPENROUTER_API_KEY
405
+ vendors: # the OpenRouter list; every entry takes a prompt
406
+ anthropic:
407
+ - model: claude-fable-5
408
+ - model: claude-opus-5
409
+ - model: claude-sonnet-5.5
410
+ - model: claude-haiku-5.5
411
+ # a list expands into one contender per value
412
+ effort: [default, low, high]
413
+ openai:
414
+ - model: gpt-4.1-mini
415
+ - model: gpt-4o-mini
416
+ - model: gpt-6.1-sol
417
+ google:
418
+ - model: gemini-2.5-flash
419
+ - model: gemini-4-argon
420
+ meta-llama:
421
+ - model: llama-3.3-70b-instruct
422
+ typesafe:
423
+ - model: jev-router # Jev on OpenRouter, a chat model
424
+ moonshotai:
425
+ - model: kimi-k3
426
+
427
+ direct: # a vendor's own API, one request type each
428
+ typesafe: # request type "typesafe": noul, choice, score
429
+ - model: jev-latest # and this requires TYPESAFE_API_KEY
430
+ ```
431
+
432
+ **`vendors:`** Each entry becomes `<vendor>/<model>`, the OpenRouter id. `effort` and `temperature` take one value or a list, and lists multiply: `effort: [low, high]` with `temperature: [0.0, 0.7]` runs four experiments for that model. Anything left out falls back to `defaults`. Flags (`-e`, `-t`, `--max-tokens`, `-p`) beat both.
433
+
434
+ **`direct:`** Each key names a provider the gem knows (today only `typesafe`). Entries take the same `{model:, effort:, temperature:, max_tokens:}` format, expand the same way, and render as `typesafe/jev-latest`. The contender carries its provider, so a direct `typesafe/jev-latest` and the OpenRouter chat model `typesafe/jev-router` never get confused. An unknown key is a configuration error naming the file and the known providers.
435
+
436
+ #### Weighted Score
437
+
438
+ > [!IMPORTANT]
439
+ > This is one of those features you never think you'd need, until you realize your results are sortable by a single column and it does not incorporate all of the others! What to do!? The answer is right here. Create a new score, weighted because it gives more or less weight to various resulting scores, and supports both results that are better when they are smaller (latency) and the ones that are better when they are larger (match %, accuracy %), etc.s
440
+
441
+ Say what matters most, and the leaderboard adds a `wscore` column, ranks by it, and names the best per dataset and overall:
442
+
443
+ ```yaml
444
+ weighted_score:
445
+ weights: # relative; a negative weight means lower is better
446
+ score: 0.4 # the overall score, or any metric your scorer declares
447
+ hallucinated_rate: -0.2 # a metric of your own
448
+ cost: -0.2
449
+ time: -0.1
450
+ errors: -0.1 # one error takes 20 points off the total
451
+ ```
452
+
453
+ `wscore = Σ(|weight| × goodness) / Σ|weight|`, from 0% to 100%, higher is better. Metrics count as they are, or as `1 - value` under a negative weight. When `errors` is weighted, errors come off the total: 20 points for one, 25 for two, 30 for three, and so on, down to 0%. A failed case has no metric scores, so the other weighted metrics do not reflect that failure. A contender with no errors earns the full positive contribution from the `errors` weight. `cost` and `time` go on a log scale between the lowest and highest in the table, so each doubling costs the same, however expensive the priciest model is. That makes a weighted score comparable only within one table.
454
+
455
+ You can weight `score`, `errors`, `time`, `cost` and your scorer's metrics. Anything else, tokens included, is refused before a model is called. A contender missing a weighted value shows `-`, and one with errors cannot be named best. Without `weighted_score`, nothing changes.
456
+
457
+ #### Request types
458
+
459
+ `request_types` is required. It identifies the kind of request the suite's adapter sends. The types belong to the providers and show in `llm-evals --help`; a suite only names them:
460
+
461
+ | Type | Provider | Meaning |
462
+ | ---------- | ---------------------------------------- | ---------------------------------------------------- |
463
+ | `prompt` | OpenRouter (everything under `vendors:`) | a chat completion: system and user text in, text out |
464
+ | `typesafe` | `direct: typesafe:` | typed questions: noul, choice, score |
465
+
466
+ A contender runs only when its provider's type is in the suite's list. Otherwise it is **refused**:
467
+
468
+ - named in the run's warnings: `typesafe/jev-latest refused: needs typesafe; this suite produces prompt`
469
+ - left out of the matrix and the leaderboard
470
+ - never scored 0: a model the suite cannot ask did not answer wrongly
471
+
472
+ `cases` prints refusals too, so a mismatch shows before anything runs. A missing, empty or unknown `request_types` is a configuration error naming the file.
473
+
474
+ ## The command line
475
+
476
+ ```bash
477
+ evals cases # list the cases, no calls
478
+ evals verify # offline checks: inputs, expected.json, adapter, scorer
479
+ evals run -m openai/gpt-4.1-mini # one model, every dataset, per-case breakdown
480
+ evals run -m anthropic/claude-haiku-5.5 -e low -d vague -c vague/00
481
+ evals run --local -o # no remote tracker; the breakdowns go to log/
482
+ evals compare # run the configured model lineup
483
+ evals compare --format html -o report.html
484
+ evals compare --format json -o results.json # save every result for later rendering
485
+ evals render results.json -o report.html # render saved results; no model calls
486
+ evals compare -n # show that matrix, call nothing
487
+ evals completion zsh # shell completion (bash too)
488
+ ```
489
+
490
+ | Command | Does | Calls models |
491
+ | ------------ | ---------------------------------------------------- | ------------ |
492
+ | `cases` | Lists cases and refused lineup entries | no |
493
+ | `verify` | Checks inputs, `expected.json`, adapter and scorer | no |
494
+ | `run` | Runs experiments; per-case breakdown and leaderboard | yes |
495
+ | `compare` | Runs experiments; leaderboard or saved report | yes |
496
+ | `render` | Renders a saved JSON run as standalone HTML | no |
497
+ | `version` | Prints the gem version (`-V`) | no |
498
+ | `completion` | Prints a bash or zsh completion script | no |
499
+
500
+ - `cases` prints one row per case with its dataset, ID, difficulty, scorer summary, and word count.
501
+ - `verify` reports empty inputs and missing or unparsable `expected.json`, then runs `adapter.verify(dataset)` and `scorer.verify`.
502
+ - `run` and `compare` run one experiment per model and dataset, up to the machine's CPU count at a time. `-j N` / `--concurrency N` sets another limit, and `-j 0` starts them all together. `-p` is separate: the cases in flight inside each experiment.
503
+
504
+ Flags shared by `run` and `compare`: `-m/--models`, `-d/--dataset` (every dataset when omitted), `-c/--case`, `-e/--effort`, `-t/--temperature`, `--max-tokens`, `-p/--parallelism`, `-j/--concurrency`, `-C/--config FILE`, `--project NAME`, `--tracker NAME`, `--local`, `--no-open`, `-n/--dry-run`, `-o/--output [FILE]`, `--format tui|html|json`, `--totals overall|variant|both`, `--display none|tui|browser`, `--autosave/--no-autosave`, `--destination DIR` and `--temp-dir DIR`. `--totals` controls the TUI totals tables: the default `overall` compares contenders across all cases, `variant` compares each case folder (such as `complete` or `vague`) across datasets, and `both` prints both views. Completed runs are saved as timestamped JSON by default, independently of the selected display. Browser and TUI reports are rendered from that JSON; `--no-autosave` uses a temporary JSON snapshot under `temp_dir`. Use `llm-evals render results.json` to regenerate the HTML report without making model calls again. `-l` and `-L` are reserved for logging and not yet implemented.
505
+
506
+ **Reading the output.** `results.display` selects no report, a terminal leaderboard, or a browser report. The TUI has one column for each metric declared by the scorer and ranks by the primary metric. It also shows a glossary built from each metric's `description`. Browser reports show global and dataset standings, sortable tables, case inputs, expected answers, scores, and diagnostics. Provider names and model IDs use separate colors. Headline standings show effort and temperature alongside each model. Every JSON snapshot records both parsed CLI options and effective run settings, including expanded model settings and selected cases. `render` turns a saved snapshot into HTML later. Progress bars and warnings go to STDERR. Output from libraries is saved to `log/<command>.warnings.<stamp>.log` so it doesn't garble the terminal display.
507
+
508
+ ## Trackers
509
+
510
+ The gem owns the run loop. A **tracker** sends results to an external service. The local JSON log is always written, and at most **one remote tracker** runs alongside it.
511
+
512
+ | Tracker | Gems | Key |
513
+ | ------------ | -------------------------------------------------- | -------------------- |
514
+ | local | none | none |
515
+ | `braintrust` | `braintrust` | `BRAINTRUST_API_KEY` |
516
+ | `logfire` | `opentelemetry-sdk`, `opentelemetry-exporter-otlp` | `LOGFIRE_TOKEN` |
517
+
518
+ Choose one with `--tracker NAME`, or let its key select it (see selection order below). Braintrust also accepts `BRAINTRUST_PROJECT_ID` or `--project`.
519
+
520
+ Each tracker sends different data:
521
+
522
+ - **local** writes `log/llm-evals/<experiment>.<stamp>.json`: settings, means, errors, usage, and one row per case (score, metrics, outcomes, diagnostics, error, usage).
523
+ - **braintrust** creates one experiment per run in the project, with one eval row per case: input, expected, output (or `_error`), the scorer's metrics as scores, and diagnostics, outcomes and usage as metadata. The experiment page opens when the run finishes, unless `--no-open`.
524
+ - **logfire** sends OTLP traces to `https://logfire-us.pydantic.dev/v1/traces` (`LOGFIRE_BASE_URL` for another region): one span per experiment (suite, project, model, provider, settings, case count, means) with one child span per case (id, dataset, variant, score, each metric, outcome counts, duration, tokens and cost, diagnostics as JSON cut at 4 KB, exceptions recorded). Raw replies are not sent unless the config has `logfire: { include_replies: true }`. There is no page to open.
525
+
526
+ **Selection order:** `--tracker NAME`, then `tracker:` in `.llm-evals.config.yml`, then the environment, where exactly one tracker's key being set selects it.
527
+
528
+ - Two keys set and nothing chosen is an error asking for `--tracker` or `tracker:`.
529
+ - `--local` (or `--tracker none`) means no remote tracker.
530
+ - A chosen tracker whose gem or key is missing fails before any model is called, naming both.
531
+ - `-v` prints which tracker was chosen and why.
532
+
533
+ Logfire has no Ruby gem of its own. It is an OTLP backend for the standard OpenTelemetry Ruby SDK. The tracker builds its own tracer provider and exporter without changing the global provider. When `OTEL_EXPORTER_OTLP_ENDPOINT` or `OTEL_EXPORTER_OTLP_HEADERS` is already set, those win over `LOGFIRE_TOKEN`, and `-v` says so. The integration sends traces only. Logfire's metrics and logs SDKs are pre-1.0.
534
+
535
+ ### Writing a tracker
536
+
537
+ A tracker is a class with three callbacks. Register it with `c.tracker` in the helper (or in a plugin gem loaded through `require:`), then choose it by name:
538
+
539
+ ```ruby
540
+ class Weave < LLM::Evals::Tracker
541
+ # Selects it when set and nothing else is chosen.
542
+ def self.env_key = "WANDB_API_KEY"
543
+
544
+ # Lazily required; named in the error when missing.
545
+ def self.gems = ["wandb"]
546
+
547
+ # Once, before the first case.
548
+ def start(experiment)
549
+ @run = Wandb.init(
550
+ project: context[:project],
551
+ name: experiment.name,
552
+ config: { model: experiment.model.id, **experiment.model.settings }
553
+ )
554
+ end
555
+
556
+ # Once per finished case, in order, under a lock.
557
+ def record(result)
558
+ @run.log(
559
+ "case" => result.case.key,
560
+ "score" => result.score&.value,
561
+ **(result.score&.metrics || {}),
562
+ "error" => result.error,
563
+ "cost_usd" => result.reply&.usage&.cost_usd
564
+ )
565
+ end
566
+
567
+ # Once, with the summary.
568
+ def finish(experiment, summary)
569
+ @run.summary.update(summary.means)
570
+ @run.finish
571
+ # Or { url: nil } when there is no page to open.
572
+ { url: @run.url }
573
+ end
574
+ end
575
+
576
+ LLM::Evals.configure { |c| c.tracker Weave }
577
+ ```
578
+
579
+ `context` holds `suite:`, `project:`, `project_id:` and `options:` (the tracker's own section of the config file, e.g. `weave: { entity: "team" }`). Return `false` from `enabled?` to turn every callback into a no-op. To target another OTLP backend, subclass `Trackers::Logfire` with different credentials.
580
+
581
+ ## Environment
582
+
583
+ | Variable | Used for |
584
+ | ----------------------------- | --------------------------------------------------------- |
585
+ | `OPENROUTER_API_KEY` | required for any `vendors:` model (OpenRouter) |
586
+ | `TYPESAFE_API_KEY` | required for a `direct: typesafe:` model |
587
+ | `BRAINTRUST_API_KEY` | the Braintrust tracker; selects it when nothing else does |
588
+ | `BRAINTRUST_PROJECT_ID` | Braintrust project UUID; beats the lineup's `project` |
589
+ | `LOGFIRE_TOKEN` | the Logfire tracker (a write token); selects it likewise |
590
+ | `LOGFIRE_BASE_URL` | Logfire region; default `https://logfire-us.pydantic.dev` |
591
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | when set, logfire uses it instead of `LOGFIRE_TOKEN` |
592
+ | `OTEL_EXPORTER_OTLP_HEADERS` | likewise |
593
+ | `LLM_EVALS_HELPER` | a helper to load instead of searching upward |
594
+
595
+ The suite loads files listed in `env_files`, without overriding variables already exported in the environment. A missing key fails the run before any model is called, naming the variable.
596
+
597
+ ## Writing a provider
598
+
599
+ A provider wraps an API. It declares the request types it supports, required keys, available models, and a `complete` method.
600
+
601
+ - A provider that serves `vendors:` ids declares which ones with `prefix`.
602
+ - A `direct?` provider is reached only through a `direct:` key matching its `name`.
603
+ - Register it with `c.provider`. Providers are checked in reverse registration order for a `vendors:` ID. OpenRouter claims every ID, so it is always checked last.
604
+
605
+ ```ruby
606
+ class Ollama < LLM::Evals::Providers::Base
607
+ HOST = "http://localhost:11434"
608
+
609
+ def name = "ollama"
610
+
611
+ # Admitted by a suite declaring prompt.
612
+ def request_type = :prompt
613
+
614
+ # Shown in --help; a suite never spells it.
615
+ def request_type_description = "a chat completion"
616
+
617
+ # Serves "ollama/<model>".
618
+ def prefix = "ollama/"
619
+
620
+ # Local, no key.
621
+ def env_keys = []
622
+
623
+ # Takes no reasoning effort.
624
+ def efforts = []
625
+
626
+ def catalog
627
+ tags = JSON.parse(Net::HTTP.get(URI("#{HOST}/api/tags")))
628
+ tags.fetch("models").map { "ollama/#{it["name"]}" }
629
+ end
630
+
631
+ def complete(model, system:, user:, json: true)
632
+ body = {
633
+ model: model.name,
634
+ system: system,
635
+ prompt: user,
636
+ format: (json ? "json" : nil),
637
+ stream: false
638
+ }.compact
639
+ response = JSON.parse(
640
+ Net::HTTP.post(URI("#{HOST}/api/generate"), JSON.generate(body)).body
641
+ )
642
+
643
+ LLM::Evals::Completion.new(
644
+ text: response.fetch("response"),
645
+ usage: {
646
+ input_tokens: response["prompt_eval_count"],
647
+ output_tokens: response["eval_count"],
648
+ cost_usd: 0.0
649
+ },
650
+ raw: response
651
+ )
652
+ end
653
+ end
654
+
655
+ LLM::Evals.configure { |c| c.provider Ollama.new }
656
+ ```
657
+
658
+ `model.effort`, `model.temperature` and `model.max_tokens` carry the contender's settings; nil means "send nothing". A transport failure should raise: the run counts it as an error, not as a wrong answer.
659
+
660
+ `Providers::TypeSafe` is a direct provider (`direct: typesafe:`, request type `typesafe`). It ships with TypeSafe's model list, key and pricing, which is enough for `verify` and the cost column. Its `complete` is not implemented yet and raises "no typesafe consumer yet": the typed request shape will be defined by the first suite that declares `request_types: [typesafe]`.
661
+
662
+ ## Public API
663
+
664
+ ```ruby
665
+ module LLM::Evals
666
+ Case = Data.define(
667
+ :id, :dataset, :variant, :input, :expected, :path, :metadata)
668
+ Usage = Data.define(
669
+ :input_tokens, :output_tokens, :cost_usd)
670
+ Completion = Data.define(
671
+ :text, :usage, :raw)
672
+ # error: the model failed, scored 0. A raise: transport failure.
673
+ Reply = Data.define(
674
+ :output, :raw, :usage, :error, :metadata)
675
+ # aggregate: :mean, skipping nil.
676
+ Metric = Data.define(
677
+ :name, :description, :aggregate, :primary)
678
+ # value: 0..1; metrics: { name => Float | nil }.
679
+ Score = Data.define(
680
+ :value, :metrics, :outcomes, :diagnostics)
681
+ RunResult = Data.define(
682
+ :experiment, :case, :reply, :score, :duration, :error)
683
+ # provider: nil for vendors:, a name for direct:.
684
+ Contender = Data.define(
685
+ :model, :effort, :temperature, :max_tokens, :provider)
686
+
687
+ class Model
688
+ attr_reader :id, :provider, :settings
689
+ def complete(system: nil, user:, json: true) = Completion
690
+ end
691
+
692
+ class Adapter
693
+ def prepare(dataset)
694
+ def call(kase, model:)
695
+ def context(kase)
696
+ def verify(dataset)
697
+ def metadata
698
+ end
699
+
700
+ class Scorer
701
+ def metrics
702
+ def call(kase, reply, context:)
703
+ def verify(kase, context:)
704
+ def summary(kase)
705
+ end
706
+
707
+ # Schema: { name => { type:, enum:, nullable: } }.
708
+ # Types: boolean, integer, number, string, enum, multi_enum.
709
+ class SchemaValidator
710
+
711
+ # One model over one dataset, on the gem's own thread pool; trackers watch.
712
+ class Experiment
713
+
714
+ # start(experiment), record(run_result), finish(experiment, summary) -> { url: }
715
+ class Tracker
716
+
717
+ # Local (always), Braintrust, Logfire (OTLP), Registry (choose).
718
+ module Trackers
719
+
720
+ # Base (request_type, direct?), Registry (for, direct, admit),
721
+ # OpenRouter, TypeSafe, Catalog.
722
+ module Providers
723
+
724
+ # EchoAdapter, FakeProvider, FakeModel.
725
+ module Testing
726
+
727
+ # Config#request_types is required; Config::REQUEST_TYPES = %w[prompt typesafe].
728
+ class Discovery, Config, Configuration, Runtime, Verify, Warnings, Launcher
729
+ end
730
+ ```
731
+
732
+ ## Development
733
+
734
+ ```bash
735
+ # Install the bundle
736
+ just install
737
+
738
+ # RuboCop, RSpec (coverage >= 95%), both example suites; no network
739
+ just ci
740
+
741
+ # Both example suites through the executable: cases, verify, compare --local
742
+ just examples
743
+
744
+ # Build the .gem into pkg/; publishing to RubyGems requires an OTP
745
+ just build
746
+ just publish
747
+
748
+ # Install the pre-commit hooks
749
+ lefthook install
750
+ ```
751
+
752
+ Specs create real directories under `Dir.mktmpdir` and never access the network. They stub `RubyLLM`, the Braintrust API classes, and `Net::HTTP`. Tracker behavior is checked with OpenTelemetry's in-memory span exporter.
753
+
754
+ ## License
755
+
756
+ MIT. See [LICENSE.txt](LICENSE.txt).
757
+
758
+ ## Author
759
+
760
+ Written by Konstantin Gredeskoul, © 2026 for the [Qualified.At](https://qualified.at) project.
761
+
762
+ ## Contributions
763
+
764
+ Are welcome! Submit a PR, an issue, we love to collaborate!