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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE.txt +21 -0
- data/README.md +764 -0
- data/exe/evals +9 -0
- data/exe/llm-evals +14 -0
- data/lib/llm/evals/adapter.rb +47 -0
- data/lib/llm/evals/case.rb +36 -0
- data/lib/llm/evals/cli/cases.rb +48 -0
- data/lib/llm/evals/cli/command.rb +163 -0
- data/lib/llm/evals/cli/compare.rb +40 -0
- data/lib/llm/evals/cli/formatting.rb +16 -0
- data/lib/llm/evals/cli/leaderboard.rb +183 -0
- data/lib/llm/evals/cli/model_colors.rb +23 -0
- data/lib/llm/evals/cli/model_command.rb +437 -0
- data/lib/llm/evals/cli/progress_bars.rb +147 -0
- data/lib/llm/evals/cli/render.rb +37 -0
- data/lib/llm/evals/cli/run.rb +69 -0
- data/lib/llm/evals/cli/standings.rb +114 -0
- data/lib/llm/evals/cli/verify.rb +24 -0
- data/lib/llm/evals/cli/version.rb +15 -0
- data/lib/llm/evals/cli.rb +43 -0
- data/lib/llm/evals/completion.rb +11 -0
- data/lib/llm/evals/config.rb +198 -0
- data/lib/llm/evals/configuration.rb +173 -0
- data/lib/llm/evals/contender.rb +31 -0
- data/lib/llm/evals/discovery.rb +95 -0
- data/lib/llm/evals/experiment.rb +158 -0
- data/lib/llm/evals/helper.rb +98 -0
- data/lib/llm/evals/launcher.rb +37 -0
- data/lib/llm/evals/metric.rb +26 -0
- data/lib/llm/evals/model.rb +55 -0
- data/lib/llm/evals/providers/base.rb +93 -0
- data/lib/llm/evals/providers/catalog.rb +32 -0
- data/lib/llm/evals/providers/open_router.rb +64 -0
- data/lib/llm/evals/providers/registry.rb +112 -0
- data/lib/llm/evals/providers/type_safe.rb +53 -0
- data/lib/llm/evals/render/reports/html.rb +266 -0
- data/lib/llm/evals/render/reports/json.rb +172 -0
- data/lib/llm/evals/render/reports/pipeline.rb +153 -0
- data/lib/llm/evals/render/reports/terminal.rb +46 -0
- data/lib/llm/evals/reply.rb +18 -0
- data/lib/llm/evals/run_result.rb +14 -0
- data/lib/llm/evals/runtime.rb +74 -0
- data/lib/llm/evals/schema_validator.rb +84 -0
- data/lib/llm/evals/score.rb +26 -0
- data/lib/llm/evals/scorer.rb +45 -0
- data/lib/llm/evals/templates/html_report.css +206 -0
- data/lib/llm/evals/templates/html_report.html.erb +184 -0
- data/lib/llm/evals/templates/html_report.js +48 -0
- data/lib/llm/evals/testing/echo_adapter.rb +29 -0
- data/lib/llm/evals/testing/fake_model.rb +18 -0
- data/lib/llm/evals/testing/fake_provider.rb +67 -0
- data/lib/llm/evals/tracker.rb +80 -0
- data/lib/llm/evals/trackers/braintrust.rb +91 -0
- data/lib/llm/evals/trackers/local.rb +78 -0
- data/lib/llm/evals/trackers/logfire.rb +143 -0
- data/lib/llm/evals/trackers/registry.rb +82 -0
- data/lib/llm/evals/usage.rb +33 -0
- data/lib/llm/evals/verify.rb +49 -0
- data/lib/llm/evals/version.rb +7 -0
- data/lib/llm/evals/warnings.rb +98 -0
- data/lib/llm/evals/weighted_score.rb +116 -0
- data/lib/llm/evals.rb +83 -0
- 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
|
+
[](https://rubygems.org/gems/llm-evals) [](https://dl.circleci.com/status-badge/redirect/gh/kigster/llm-evals/tree/main) [](https://github.com/kigster/llm-evals/actions/workflows/main.yml) 
|
|
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
|
+

|
|
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!
|