llm-evals 0.4.0 → 0.5.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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +163 -87
  4. data/exe/llm-evals +60 -5
  5. data/lib/.DS_Store +0 -0
  6. data/lib/llm/.DS_Store +0 -0
  7. data/lib/llm/evals/actions/adapter.rb +50 -0
  8. data/lib/llm/evals/actions/error_budget.rb +63 -0
  9. data/lib/llm/evals/actions/error_diagnosis.rb +51 -0
  10. data/lib/llm/evals/actions/error_streak.rb +83 -0
  11. data/lib/llm/evals/actions/experiment.rb +251 -0
  12. data/lib/llm/evals/actions/verify.rb +61 -0
  13. data/lib/llm/evals/actions.rb +9 -0
  14. data/lib/llm/evals/cli/cases.rb +10 -0
  15. data/lib/llm/evals/cli/command.rb +32 -99
  16. data/lib/llm/evals/cli/compare.rb +0 -20
  17. data/lib/llm/evals/cli/contender_selector.rb +61 -0
  18. data/lib/llm/evals/cli/error_pause.rb +81 -0
  19. data/lib/llm/evals/cli/experiment_pool.rb +97 -0
  20. data/lib/llm/evals/cli/formatting.rb +8 -0
  21. data/lib/llm/evals/cli/leaderboard.rb +66 -12
  22. data/lib/llm/evals/cli/model_colors.rb +3 -0
  23. data/lib/llm/evals/cli/model_command.rb +152 -315
  24. data/lib/llm/evals/cli/model_options.rb +80 -0
  25. data/lib/llm/evals/cli/option_groups.rb +32 -0
  26. data/lib/llm/evals/cli/progress_bars.rb +92 -5
  27. data/lib/llm/evals/cli/render.rb +6 -6
  28. data/lib/llm/evals/cli/report_dispatcher/console.rb +65 -0
  29. data/lib/llm/evals/cli/report_dispatcher/html_output.rb +50 -0
  30. data/lib/llm/evals/cli/report_dispatcher/json_output.rb +29 -0
  31. data/lib/llm/evals/cli/report_dispatcher/output.rb +56 -0
  32. data/lib/llm/evals/cli/report_dispatcher/pipeline_output.rb +38 -0
  33. data/lib/llm/evals/cli/report_dispatcher.rb +133 -0
  34. data/lib/llm/evals/cli/run.rb +13 -22
  35. data/lib/llm/evals/cli/run_planner.rb +97 -0
  36. data/lib/llm/evals/cli/run_record.rb +68 -0
  37. data/lib/llm/evals/cli/standings.rb +39 -17
  38. data/lib/llm/evals/cli/status_box.rb +51 -0
  39. data/lib/llm/evals/cli/table.rb +103 -0
  40. data/lib/llm/evals/cli/text.rb +22 -0
  41. data/lib/llm/evals/cli/tracker_resolver.rb +52 -0
  42. data/lib/llm/evals/cli/upgrade_config.rb +60 -0
  43. data/lib/llm/evals/cli/verify.rb +5 -1
  44. data/lib/llm/evals/cli/version.rb +4 -0
  45. data/lib/llm/evals/cli/weights.rb +48 -0
  46. data/lib/llm/evals/cli.rb +39 -6
  47. data/lib/llm/evals/config/contender_builder.rb +30 -0
  48. data/lib/llm/evals/config/error_limits.rb +41 -0
  49. data/lib/llm/evals/config/listing.rb +153 -0
  50. data/lib/llm/evals/config/results.rb +42 -0
  51. data/lib/llm/evals/config/score_weights.rb +25 -0
  52. data/lib/llm/evals/config/upgrade.rb +107 -0
  53. data/lib/llm/evals/config.rb +90 -79
  54. data/lib/llm/evals/datasets/case.rb +46 -0
  55. data/lib/llm/evals/datasets/discovery.rb +143 -0
  56. data/lib/llm/evals/datasets.rb +9 -0
  57. data/lib/llm/evals/launcher.rb +57 -3
  58. data/lib/llm/evals/models/contender.rb +51 -0
  59. data/lib/llm/evals/models/model.rb +87 -0
  60. data/lib/llm/evals/models/throttle.rb +170 -0
  61. data/lib/llm/evals/models.rb +9 -0
  62. data/lib/llm/evals/providers/base.rb +61 -16
  63. data/lib/llm/evals/providers/catalog.rb +5 -2
  64. data/lib/llm/evals/providers/open_router.rb +54 -7
  65. data/lib/llm/evals/providers/registry.rb +49 -16
  66. data/lib/llm/evals/providers/type_safe/client.rb +67 -0
  67. data/lib/llm/evals/providers/type_safe.rb +113 -12
  68. data/lib/llm/evals/providers.rb +9 -0
  69. data/lib/llm/evals/render/reports/call_stats_table.rb +44 -0
  70. data/lib/llm/evals/render/reports/html/dataset_section.rb +51 -0
  71. data/lib/llm/evals/render/reports/html/format.rb +38 -0
  72. data/lib/llm/evals/render/reports/html/global_standings.rb +41 -0
  73. data/lib/llm/evals/render/reports/html/ranking.rb +86 -0
  74. data/lib/llm/evals/render/reports/html/view_model.rb +116 -0
  75. data/lib/llm/evals/render/reports/html.rb +60 -181
  76. data/lib/llm/evals/render/reports/json/decoder.rb +137 -0
  77. data/lib/llm/evals/render/reports/json/encoder.rb +106 -0
  78. data/lib/llm/evals/render/reports/json.rb +38 -141
  79. data/lib/llm/evals/render/reports/pipeline.rb +55 -20
  80. data/lib/llm/evals/render/reports/terminal.rb +8 -1
  81. data/lib/llm/evals/render.rb +9 -0
  82. data/lib/llm/evals/results/provider_response.rb +25 -0
  83. data/lib/llm/evals/results/reply.rb +38 -0
  84. data/lib/llm/evals/results/run_outcome.rb +15 -0
  85. data/lib/llm/evals/results/run_result.rb +40 -0
  86. data/lib/llm/evals/results/score.rb +45 -0
  87. data/lib/llm/evals/results/warnings.rb +149 -0
  88. data/lib/llm/evals/results.rb +9 -0
  89. data/lib/llm/evals/runtime/configuration.rb +295 -0
  90. data/lib/llm/evals/runtime/environment.rb +90 -0
  91. data/lib/llm/evals/runtime/helper.rb +153 -0
  92. data/lib/llm/evals/runtime.rb +2 -67
  93. data/lib/llm/evals/scoring/metric.rb +47 -0
  94. data/lib/llm/evals/scoring/schema_validator.rb +102 -0
  95. data/lib/llm/evals/scoring/scorer.rb +49 -0
  96. data/lib/llm/evals/scoring/weighted_score.rb +148 -0
  97. data/lib/llm/evals/scoring.rb +9 -0
  98. data/lib/llm/evals/stats/call_stats.rb +91 -0
  99. data/lib/llm/evals/stats/usage.rb +46 -0
  100. data/lib/llm/evals/stats.rb +29 -0
  101. data/lib/llm/evals/testing/echo_adapter.rb +14 -9
  102. data/lib/llm/evals/testing/fake_model.rb +8 -6
  103. data/lib/llm/evals/testing/fake_provider.rb +39 -8
  104. data/lib/llm/evals/testing.rb +9 -0
  105. data/lib/llm/evals/trackers/base.rb +110 -0
  106. data/lib/llm/evals/trackers/braintrust.rb +40 -3
  107. data/lib/llm/evals/trackers/local.rb +38 -2
  108. data/lib/llm/evals/trackers/logfire.rb +56 -3
  109. data/lib/llm/evals/trackers/registry.rb +29 -7
  110. data/lib/llm/evals/trackers.rb +9 -0
  111. data/lib/llm/evals/version.rb +3 -1
  112. data/lib/llm/evals.rb +17 -7
  113. metadata +91 -24
  114. data/lib/llm/evals/adapter.rb +0 -47
  115. data/lib/llm/evals/case.rb +0 -36
  116. data/lib/llm/evals/completion.rb +0 -11
  117. data/lib/llm/evals/configuration.rb +0 -173
  118. data/lib/llm/evals/contender.rb +0 -31
  119. data/lib/llm/evals/discovery.rb +0 -95
  120. data/lib/llm/evals/experiment.rb +0 -158
  121. data/lib/llm/evals/helper.rb +0 -98
  122. data/lib/llm/evals/metric.rb +0 -26
  123. data/lib/llm/evals/model.rb +0 -55
  124. data/lib/llm/evals/reply.rb +0 -18
  125. data/lib/llm/evals/run_result.rb +0 -14
  126. data/lib/llm/evals/schema_validator.rb +0 -84
  127. data/lib/llm/evals/score.rb +0 -26
  128. data/lib/llm/evals/scorer.rb +0 -45
  129. data/lib/llm/evals/tracker.rb +0 -80
  130. data/lib/llm/evals/usage.rb +0 -33
  131. data/lib/llm/evals/verify.rb +0 -49
  132. data/lib/llm/evals/warnings.rb +0 -98
  133. data/lib/llm/evals/weighted_score.rb +0 -116
  134. /data/lib/llm/evals/{templates → render/templates}/html_report.css +0 -0
  135. /data/lib/llm/evals/{templates → render/templates}/html_report.html.erb +0 -0
  136. /data/lib/llm/evals/{templates → render/templates}/html_report.js +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2ae773e535e412da4e212478e21e0d8cbb828e41393328a2e76344511e55694c
4
- data.tar.gz: be95ec246ee851a62230279e2892541415baab88b8220a3ed2f7f507c41654fa
3
+ metadata.gz: 1df9a8ec28e61b18c3b929fbf1aff863f01b4083e762dcc8dfa24b0a4c107da7
4
+ data.tar.gz: ae89677e193f2c6369663df56d92276de19b2d4be3de5c83502612840f79d96e
5
5
  SHA512:
6
- metadata.gz: 7d880476a22a64188d8266071248f2e2cc9fe06b2d57680f0d52045bf5d32bd22336e1664f5d79186721a1aa9ef44b9c266e67090d5f0deadbe873cbaff7f572
7
- data.tar.gz: f68267cbca0e553ff8c524e407de65a6e448167f06b53ddb8506e3b333b1a2a6663f2e05ef5e1db19b3684ea7d177ab8cbf52b22d83f024d7001ca892312d959
6
+ metadata.gz: 719e4775de99d347928c359df4a92ae253c5686a153e9970acb02e0baf23a8dd17c0811e2d6476993f1b563b1bef28d02efc721a0b44440a47db25999fc90bec
7
+ data.tar.gz: 1cd81a2ca9d699ca1b1f15ce4ac3fca1af31288ada47e4fbff395255ad997e04d2a4e9b37e7303696c4b0fe9b2c1bb7dce22b2b133ca93a9aa7f5d8c54bfc8a3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Breaking: `results.display` now defaults to `both`: every run prints the terminal summary, builds the HTML report from its JSON and opens it in the browser. Use `--display tui` or `--no-open` to turn that off.
6
+
7
+ - Group the `run` and `compare` flags in `--help` under General, TUI and Browser Report.
8
+
9
+ - Breaking: move every class out of the top of `LLM::Evals` into a module of its own. There are no aliases; rename as follows:
10
+
11
+ | Module | Constants |
12
+ | ---------------------- | ---------------------------------------------------------------------------------------------- |
13
+ | `LLM::Evals::Runtime` | `Configuration`, `Helper`; `Runtime` is now `Runtime::Environment` |
14
+ | `LLM::Evals::Datasets` | `Case`, `Discovery` |
15
+ | `LLM::Evals::Scoring` | `Scorer`, `Metric`, `WeightedScore`, `SchemaValidator` |
16
+ | `LLM::Evals::Models` | `Model`, `Contender`, `Throttle` |
17
+ | `LLM::Evals::Actions` | `Experiment`, `Adapter`, `Verify`, `ErrorBudget` |
18
+ | `LLM::Evals::Results` | `Reply`, `Score`, `ProviderResponse` (was `Completion`), `RunResult`, `RunOutcome`, `Warnings` |
19
+ | `LLM::Evals::Stats` | `CallStats`, `Usage` (`Stats` itself stays) |
20
+
21
+ `LLM::Evals::Tracker` is now `LLM::Evals::Trackers::Base`, and `LLM::Evals::ConfigUpgrade` is `LLM::Evals::Config::Upgrade`. `LLM::Evals.configure` and `.configuration` are unchanged.
22
+
23
+ - Add `Model#ask` and `Providers::TypeSafe#ask`: typed questions to TypeSafe's API, through the same limits and retries.
24
+
25
+ - Add `evals upgrade-config`, which rewrites a lineup to the current format, keeping its comments and leaving `<file>.bak`.
26
+
27
+ - Breaking: the lineup lists models under a provider section (`openrouter: { vendors: ... }`, `typesafe: { models: ... }`). Top-level `vendors:` and `direct:` are errors.
28
+
29
+ - Add `rate_limit: { burst, per_minute }` per provider, vendor and model, through `ruby-limiter`. Waiting is left out of `time`.
30
+
31
+ - Turn RubyLLM's retries off and retry here, honoring `Retry-After` across a vendor's calls, up to 5 attempts.
32
+
33
+ - Add `errors.abort_after` to the lineup: a run stops once it has `total` errors and, if set, `percent` of finished calls have failed. Partial results are kept and the command exits non-zero.
34
+
35
+ - Add `evals weights`, which prints each weighted column's direction, weight and real share of the score, and names in red any scorer metric the weights leave out.
36
+
37
+ - Report calls per provider, vendor and model: requests, errors, requests per second, average and 95th percentile latency, in the terminal and as `call_stats` in the JSON. Add `latency` and `p95` as columns `weighted_score` can weight.
38
+
39
+ - Draw failed cases in red on the progress bars of `run` and `compare`, so a failing model or account shows while the run is still going.
40
+
3
41
  ## 0.4.0 (2026-10-30)
4
42
 
5
43
  Renamed from `agent-evals`: gem `llm-evals`, namespace `LLM::Evals`, executable `evals` (with `llm-evals` as an identical spare, for a system where `evals` is taken; shell completion targets `evals`), lineup file `.llm-evals.config.yml`. The old names are gone. The version continues from `agent-evals` 0.3.1.
data/README.md CHANGED
@@ -8,15 +8,20 @@ A flexible CLI for running multi-case evaluation suites and comparing LLMs and p
8
8
 
9
9
  > [!IMPORTANT]
10
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
11
  >
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).
12
+ > 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 the runner uses [dry-rb](https://dry-rb.org/) and my extensions [dry-cli.tools](https://dry-cli.tools).
13
+ >
14
+ > In order run evals against pretty much any model, you need an account, with credits, on an [OpenRouter](https://openrouter.ai), and set the `OPENROUTER_API_KEY` to the key generated in OpenRouter.
16
15
  >
17
- > I originally developed the gem for [Qualified.At](https://qualified.at), a Ruby on Rails SaaS product.
16
+ > If you also want to include [Typesafe's Jev model](https://typesafe.ai/blog/introducing-system-one-models-and-jev), this gem has a direct connector for Jev, so you'll need to set `TYPESAFE_API_KEY` in your environment as well.
17
+
18
+ ______________________________________________________________________
19
+
20
+ 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).
21
+
22
+ > _I originally developed this gem for my fremium product [Qualified.At](https://qualified.at), a Ruby on Rails SaaS product._
18
23
 
19
- ## Evals are unit tests for probabilistic software
24
+ ## Evals are unit tests for probabilistic software made by AI
20
25
 
21
26
  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
27
 
@@ -29,6 +34,8 @@ An eval checks whether a model meets the behavior you expect on a set of represe
29
34
  - [Independent Eval Research](https://www.braintrust.dev/evals) (Braintrust)
30
35
  - [How to Build an LLM Eval Pipeline in GitHub Actions](https://www.braintrust.dev/articles/llm-eval-pipeline-github-actions) (Braintrust)
31
36
 
37
+ ______________________________________________________________________
38
+
32
39
  ### A real-world example: Qualified.At
33
40
 
34
41
  > [!NOTE]
@@ -49,16 +56,50 @@ A tax customer might write: *"I'm married with three children, filing jointly, h
49
56
  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
57
 
51
58
  > [!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.
59
+ > So, **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. **That very question is why I built this gem.**
60
+
61
+ ______________________________________________________________________
62
+
63
+ ## Weighted Scoring
64
+
65
+ Quite importantly, and often skipped over in the Eval design, is a concept of a **single weighted score**, that attaches fixed weights to various metrics.
66
+
67
+ Evals are naturally a **multi-variate optimization problem**, something that has been solved in statistics and operations research many decades ago.
53
68
 
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.
69
+ > [!NOTE]
70
+ > So if the **cost** is the most important factor, we give the cost the largest weight. If the **accuracy** is the most important, then it gets the highest weight. The weights configuration handles metrics that improve when they increase, as well as those that improve when they decrease.
71
+
72
+ Here is a weight section definitions from the YAML configuration file `evals/.llm-evals.config.yml` (see below) that generated the HTML report which follows:
73
+
74
+ ![weights](docs/img/yaml-weights.avif)
55
75
 
56
- ![LLM evaluation HTML report](docs/img/llm-evals-html-report.avif)
76
+ ## Running Evals
77
+
78
+ Considering your `evals` folder is setup correctly, and the config file lives right under the `evals`, as `.llm-evals.config.yml`, you should just be able to run:
79
+
80
+ ```bash
81
+ export OPENROUTER_API_KEY=....
82
+ export TYPESAFE_API_KEY=...
83
+
84
+ evals run
85
+ ```
57
86
 
58
87
  > [!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.*
88
+ > You need just those two because the evals go through ***OpenRouter***, and the only exception at the moment is Jev, which has a direct driver.
89
+
90
+ The following is a screenshot of an HTML report from running evals in the [Qualified.At](https://qualified.at) Rails application, which compares various models on accuracy, completeness, latency, cost, and other metrics for the extraction task of the first question the lead answers.
91
+
92
+ I set the *temperature* to 0.1 to get the least "creative" and most "precise" results, and for most models I am trying two sets of effort: "*high*" and "*low*":
93
+
94
+ ![HTML Report](docs/img/qualified-at-evals.avif)
60
95
 
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).
96
+ ### A Specific Experiment
97
+
98
+ While the above shows the summarized results across 10 DSL examples, each with 4 prompts (so 40 total), the following screenshot from the same HTML report shows just one DSL use-case (tax collection) and how different the results are.
99
+
100
+ I've highlighted in green the top models, their low pricing, and the overall winner for tax preparation — and not that in this case it's not the same model as the global winner. This is important.
101
+
102
+ ![taxes](docs/img/one-dataset-results.avif)
62
103
 
63
104
  ## Datasets
64
105
 
@@ -142,14 +183,20 @@ The recipes change into each suite's folder and set `RUBYLIB` for the config-onl
142
183
 
143
184
  ### Where to run from
144
185
 
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:
186
+ `llm-evals` finds a suite by searching **upward from the current directory** for `evals/eval_helper.rb` or `evals/.llm-evals.config.yml`. If that finds nothing, it searches **up to four directories below** the current directory for `.llm-evals.config.yml`, skipping hidden directories and `node_modules`, `vendor`, `tmp`, `log`, `pkg` and `coverage`. The shallowest file wins, then the first by name. Run from the repository root, it finds a fixture:
146
187
 
147
188
  ```text
148
189
  $ bundle exec exe/evals cases
149
- error: no suite is configured: register an adapter and a scorer in evals/eval_helper.rb, ...
190
+
191
+ ┌ Info ────────────────────────────────────────────────────────────────────────┐
192
+ │ Evals configuration: config_only_suite │
193
+ │ File: spec/fixtures/config_only_suite/evals/.llm-evals.config.yml │
194
+ └──────────────────────────────────────────────────────────────────────────────┘
150
195
  ```
151
196
 
152
- Run the command from a suite's folder (the parent of its `evals/` directory), or pass the helper path with `--helper`.
197
+ #### What is this?
198
+
199
+ A file the search above ran, and found the eval suite four directories deep. Your shell stays where it was. That green box is the first thing a run prints, and it follows the help. When there is no file, the box is a yellow warning. `--no-search` turns the search off, and `-C`/`--config` skips it.
153
200
 
154
201
  ### The toy suite (`eval_helper.rb` style)
155
202
 
@@ -188,7 +235,7 @@ Files to look at:
188
235
  | --------------------------------------- | ---------------------------------------------------------- |
189
236
  | `evals/eval_helper.rb` | `LLM::Evals.configure`: suite, cases glob, adapter, scorer |
190
237
  | `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 |
238
+ | `evals/.llm-evals.config.yml` | the lineup, including a TypeSafe entry the suite refuses |
192
239
  | `evals/datasets/` | the case layout on disk |
193
240
 
194
241
  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.
@@ -211,7 +258,7 @@ Use the toy suite as a template:
211
258
 
212
259
  1. Copy its `evals/` folder into your project.
213
260
  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.
261
+ 1. List real models under `openrouter:` in `.llm-evals.config.yml` and drop the `fake:` section.
215
262
  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
263
 
217
264
  ## Install
@@ -257,43 +304,51 @@ require "bundler/setup"
257
304
  require "llm/evals"
258
305
  require "my_gem"
259
306
 
260
- class MyAdapter < LLM::Evals::Adapter
307
+ class MyAdapter < LLM::Evals::Actions::Adapter
261
308
  # Once per dataset, outside the threads.
262
309
  def prepare(dataset) = @schema = MyGem.schema_for(dataset)
263
310
 
264
311
  def call(kase, model:)
265
- completion = model.complete(
266
- system: MyGem.system_prompt(@schema), user: kase.input, json: true)
267
-
312
+ provider_response = model.complete(
313
+ system: MyGem.system_prompt(@schema), user: kase.input, json: true
314
+ )
315
+
268
316
  # 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)
317
+ output = MyGem.parse(provider_response.text)
318
+ LLM::Evals::Results::Reply.new(
319
+ output: output,
320
+ raw: provider_response.text,
321
+ usage: provider_response.usage
322
+ )
271
323
  rescue MyGem::NotJSON => e
272
324
  # The model's failure: scored 0.
273
- LLM::Evals::Reply.new(error: e.message, raw: completion&.text)
325
+ LLM::Evals::Results::Reply.new(
326
+ error: e.message,
327
+ raw: provider_response&.text
328
+ )
274
329
  end
275
330
 
276
331
  # What the scorer needs beyond the case.
277
- def context(kase) = { schema: @schema }
332
+ def context(_kase) = { schema: @schema }
278
333
 
279
334
  # Offline, for `llm-evals verify`.
280
335
  def verify(dataset) = MyGem.schema_problems(dataset)
281
336
  end
282
337
 
283
- class MyScorer < LLM::Evals::Scorer
338
+ class MyScorer < LLM::Evals::Scoring::Scorer
284
339
  def metrics
285
340
  [
286
- LLM::Evals::Metric.new(
287
- name: "field_accuracy",
341
+ LLM::Evals::Scoring::Metric.new(
342
+ name: "field_accuracy",
288
343
  description: "Mean per-field score.",
289
- primary: true
344
+ primary: true
290
345
  ),
291
- LLM::Evals::Metric.new(
292
- name: "exact_match",
346
+ LLM::Evals::Scoring::Metric.new(
347
+ name: "exact_match",
293
348
  description: "1 when every field is right."
294
349
  ),
295
- LLM::Evals::Metric.new(
296
- name: "restraint",
350
+ LLM::Evals::Scoring::Metric.new(
351
+ name: "restraint",
297
352
  description: "Of the fields the text never answers, the share left null."
298
353
  )
299
354
  ]
@@ -304,22 +359,22 @@ class MyScorer < LLM::Evals::Scorer
304
359
  mean = scores.values.sum / scores.size
305
360
  misses = scores.reject { |_, v| v == 1.0 }.keys
306
361
 
307
- LLM::Evals::Score.new(
308
- value: mean,
309
- metrics: {
362
+ LLM::Evals::Results::Score.new(
363
+ value: mean,
364
+ metrics: {
310
365
  "field_accuracy" => mean,
311
- "exact_match" => scores.values.all?(1.0) ? 1.0 : 0.0,
366
+ "exact_match" => scores.values.all?(1.0) ? 1.0 : 0.0,
312
367
  # nil: this case does not apply; it is skipped, not zeroed.
313
- "restraint" => nil
368
+ "restraint" => nil
314
369
  },
315
- diagnostics: misses.to_h do |f|
316
- [f, { want: kase.expected[f], got: reply.output[f] }]
370
+ diagnostics: misses.to_h do |f|
371
+ [f, { want: kase.expected[f], got: reply.output[f] }]
317
372
  end
318
373
  )
319
374
  end
320
375
 
321
376
  def verify(kase, context:)
322
- LLM::Evals::SchemaValidator.new(context[:schema]).problems(kase.expected)
377
+ LLM::Evals::Scoring::SchemaValidator.new(context[:schema]).problems(kase.expected)
323
378
  end
324
379
 
325
380
  def summary(kase) = "#{kase.expected.count { |_, v| !v.nil? }}/#{kase.expected.size} answered"
@@ -333,6 +388,7 @@ LLM::Evals.configure do |c|
333
388
  # After the env files load, before any model is called.
334
389
  c.before_run { MyGem.reset_configuration! }
335
390
  end
391
+
336
392
  ```
337
393
 
338
394
  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.
@@ -399,42 +455,45 @@ results:
399
455
  autosave: true # save every completed run as JSON (default)
400
456
  destination: ./evals/results/
401
457
  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
458
+ display: both # none, tui, browser or both (default)
459
+
460
+ # A section named after a provider states the key it needs.
461
+ openrouter: # requires OPENROUTER_API_KEY; every entry takes a prompt
462
+ rate_limit: { burst: 1000, per_minute: 500 }
463
+ vendors:
464
+ anthropic:
465
+ rate_limit: { burst: 10, per_minute: 50 }
466
+ models:
467
+ - model: claude-opus-5 # the vendor's limit only
468
+ - model: claude-fable-5 # both limits
469
+ rate_limit: { burst: 5, per_minute: 20 }
470
+ - model: claude-haiku-5.5
471
+ # a list expands into one contender per value
472
+ effort: [default, low, high]
473
+ openai: # no limit of its own
474
+ models:
475
+ - model: gpt-6.1-sol
476
+ typesafe:
477
+ models:
478
+ - model: jev-router # Jev on OpenRouter, a chat model
479
+
480
+ typesafe: # requires TYPESAFE_API_KEY; request type "typesafe": noul, choice, score
481
+ models:
482
+ - jev-latest # a bare name is the same as `- model: jev-latest`
430
483
  ```
431
484
 
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.
485
+ **Provider sections.** Each top-level key with `vendors:` or `models:` names a provider. An entry under `vendors:` becomes `<vendor>/<model>`, the OpenRouter id. An entry under `models:` becomes the provider's prefix and the name, such 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 provider or key is a configuration error naming the file. The old top-level `vendors:` and `direct:` are errors too; `evals upgrade-config` rewrites them, keeping your comments and leaving `<file>.bak`, and `-n` prints the result instead.
486
+
487
+ **Settings.** `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
488
 
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.
489
+ **`rate_limit`.** It goes on a provider, a vendor or a model, and needs both numbers: the first `burst` calls of the run go at once, then `per_minute`, evenly spaced. A burst is spent once per run and does not refill. A model listed twice shares one limit, so setting it on both entries is an error. A call waits on every limit that matches it. Limits hold for one process across all `-j` and `-p`, and models given with `-m` obey them too. Time spent waiting is left out of `time`, `latency` and `p95`; `time` subtracts the wait averaged across the experiment's workers.
490
+
491
+ **Retries.** RubyLLM's retries are off. Rate limits, overloads, server errors and dropped connections are retried up to 5 attempts in all, and a retry shuts that vendor for every thread, for as long as `Retry-After` says or for 1, 2, 4 then 8 seconds when it says nothing. A provider asking for more than 5 minutes is not retried; the case counts as an error.
435
492
 
436
493
  #### Weighted Score
437
494
 
495
+ We already chatted about the weghted score above, but this is a deeper dive.
496
+
438
497
  > [!IMPORTANT]
439
498
  > 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
499
 
@@ -452,16 +511,33 @@ weighted_score:
452
511
 
453
512
  `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
513
 
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.
514
+ `evals weights` prints each column's direction and real share of the score: `↓` in bold blue where low is good, `↑` in bold yellow where high is good. A scorer metric the weights leave out is listed in red with no values. Weights need not add up to 1.
515
+
516
+ You can weight `score`, `errors`, `time`, `cost`, `latency`, `p95` and your scorer's metrics. `latency` and `p95` are the average and 95th percentile seconds of the calls that worked (across datasets, a contender's `p95` is the average of each dataset's, an approximation good enough to rank by), while `time` is how long the experiment took, waiting included. 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.
517
+
518
+ #### Aborting a failing run
519
+
520
+ ```yaml
521
+ errors:
522
+ abort_after:
523
+ total: 1000 # required
524
+ percent: 60 # optional: also needs 60% of finished calls to have failed
525
+ ```
526
+
527
+ Both limits must be met. A case counts as an error when the call raises or the model's reply is an error, such as one that is not JSON. Calls in flight finish, nothing new starts, the report is written from what finished, and the command exits non-zero. Without `errors`, a run never aborts.
528
+
529
+ #### When every call fails
530
+
531
+ With no configuration, 100 failed calls in a row pause the run: no new calls start, and it prints the error messages and which models, datasets and cases are affected, or that all of them are. It then asks whether to keep running. If you carry on, another 100 failures in a row ask again. If you stop, the partial results are reported and the command exits 1. `-y` / `--yes` prints the same details and keeps running. When stdin is not a terminal and `--yes` is absent, the run stops, since there is nobody to ask.
456
532
 
457
533
  #### Request types
458
534
 
459
535
  `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
536
 
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 |
537
+ | Type | Provider | Meaning |
538
+ | ---------- | ------------------------------------------- | ---------------------------------------------------- |
539
+ | `prompt` | OpenRouter (everything under `openrouter:`) | a chat completion: system and user text in, text out |
540
+ | `typesafe` | `typesafe:` | typed questions: noul, choice, score |
465
541
 
466
542
  A contender runs only when its provider's type is in the suite's list. Otherwise it is **refused**:
467
543
 
@@ -503,7 +579,7 @@ evals completion zsh # shell completion (bash too)
503
579
 
504
580
  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
581
 
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.
582
+ **Reading the output.** `results.display` selects no report, a terminal leaderboard, a browser report, or, by default, both: the HTML report is built from the run's JSON and opened in the browser. `--display tui` skips the page, and `--no-open` writes it without opening it. 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
583
 
508
584
  ## Trackers
509
585
 
@@ -537,7 +613,7 @@ Logfire has no Ruby gem of its own. It is an OTLP backend for the standard OpenT
537
613
  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
614
 
539
615
  ```ruby
540
- class Weave < LLM::Evals::Tracker
616
+ class Weave < LLM::Evals::Trackers::Base
541
617
  # Selects it when set and nothing else is chosen.
542
618
  def self.env_key = "WANDB_API_KEY"
543
619
 
@@ -582,8 +658,8 @@ LLM::Evals.configure { |c| c.tracker Weave }
582
658
 
583
659
  | Variable | Used for |
584
660
  | ----------------------------- | --------------------------------------------------------- |
585
- | `OPENROUTER_API_KEY` | required for any `vendors:` model (OpenRouter) |
586
- | `TYPESAFE_API_KEY` | required for a `direct: typesafe:` model |
661
+ | `OPENROUTER_API_KEY` | required for any `openrouter:` model |
662
+ | `TYPESAFE_API_KEY` | required for a `typesafe:` model |
587
663
  | `BRAINTRUST_API_KEY` | the Braintrust tracker; selects it when nothing else does |
588
664
  | `BRAINTRUST_PROJECT_ID` | Braintrust project UUID; beats the lineup's `project` |
589
665
  | `LOGFIRE_TOKEN` | the Logfire tracker (a write token); selects it likewise |
@@ -598,9 +674,9 @@ The suite loads files listed in `env_files`, without overriding variables alread
598
674
 
599
675
  A provider wraps an API. It declares the request types it supports, required keys, available models, and a `complete` method.
600
676
 
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.
677
+ - A provider declares the ids it serves with `prefix`, and its section in the lineup carries its `name`.
678
+ - A `direct?` provider is reached only through its own section.
679
+ - Register it with `c.provider`. Providers are checked in reverse registration order for an ID outside a direct provider's section. OpenRouter claims every ID, so it is always checked last.
604
680
 
605
681
  ```ruby
606
682
  class Ollama < LLM::Evals::Providers::Base
@@ -640,7 +716,7 @@ class Ollama < LLM::Evals::Providers::Base
640
716
  Net::HTTP.post(URI("#{HOST}/api/generate"), JSON.generate(body)).body
641
717
  )
642
718
 
643
- LLM::Evals::Completion.new(
719
+ LLM::Evals::Results::ProviderResponse.new(
644
720
  text: response.fetch("response"),
645
721
  usage: {
646
722
  input_tokens: response["prompt_eval_count"],
@@ -657,7 +733,7 @@ LLM::Evals.configure { |c| c.provider Ollama.new }
657
733
 
658
734
  `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
735
 
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]`.
736
+ `Providers::TypeSafe` is a direct provider (`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
737
 
662
738
  ## Public API
663
739
 
@@ -667,7 +743,7 @@ module LLM::Evals
667
743
  :id, :dataset, :variant, :input, :expected, :path, :metadata)
668
744
  Usage = Data.define(
669
745
  :input_tokens, :output_tokens, :cost_usd)
670
- Completion = Data.define(
746
+ ProviderResponse = Data.define(
671
747
  :text, :usage, :raw)
672
748
  # error: the model failed, scored 0. A raise: transport failure.
673
749
  Reply = Data.define(
@@ -680,13 +756,13 @@ module LLM::Evals
680
756
  :value, :metrics, :outcomes, :diagnostics)
681
757
  RunResult = Data.define(
682
758
  :experiment, :case, :reply, :score, :duration, :error)
683
- # provider: nil for vendors:, a name for direct:.
759
+ # provider: nil, or a direct provider's name.
684
760
  Contender = Data.define(
685
761
  :model, :effort, :temperature, :max_tokens, :provider)
686
762
 
687
763
  class Model
688
764
  attr_reader :id, :provider, :settings
689
- def complete(system: nil, user:, json: true) = Completion
765
+ def complete(system: nil, user:, json: true) = ProviderResponse
690
766
  end
691
767
 
692
768
  class Adapter
data/exe/llm-evals CHANGED
@@ -4,11 +4,66 @@
4
4
  # No Bundler here: a Gemfile in the user's directory must not decide which
5
5
  # gems load. The suite's own eval_helper.rb may `require "bundler/setup"`.
6
6
  $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
7
- require "llm/evals"
8
7
 
9
- puts "\e[1;31mERROR: \e[0;33mThis gem comes with an executable named 'evals'"
10
- puts " You invoked 'llm-evals'. Please use 'evals' to use the gems's CLI.\n\e[0m"
8
+ # warn prints nothing under -W0, and the welcome is only ever shown once.
9
+ # rubocop:disable Style/StderrPuts
10
+ require "fileutils"
11
+ require "yaml"
12
+ require "date"
13
+
14
+ module LLM
15
+ module Evals
16
+ # The key under which the semaphore file records the first run.
17
+ WARNING_HASH_KEY = "warning_shown_on"
18
+ # Where the semaphore file lives.
19
+ SEMAPHORE_DIR = "#{Dir.home}/.local/share/llm-evals".freeze
20
+ # Remembers that the welcome was shown.
21
+ SEMAPHORE_FILE = "#{SEMAPHORE_DIR}/warning-semaphore.yml".freeze
22
+
23
+ # What the semaphore file held.
24
+ SEMAPHORE_CONFIG = if File.size?(SEMAPHORE_FILE)
25
+ YAML.safe_load_file(SEMAPHORE_FILE, aliases: true)
26
+ else
27
+ {}
28
+ end
29
+
30
+ unless SEMAPHORE_CONFIG.is_a?(Hash) && SEMAPHORE_CONFIG[WARNING_HASH_KEY]
31
+ FileUtils.mkdir_p(SEMAPHORE_DIR)
32
+ File.write(SEMAPHORE_FILE, { WARNING_HASH_KEY => DateTime.now.to_s }.to_yaml)
33
+
34
+ $stderr.puts "\e[0;33mWelcome to the llm-evals CLI!\n\n"
11
35
 
12
- puts "Thanks for using llm-evals!"
36
+ $stderr.puts "\e[1;31mNOTE: \e[0;33mThis gem comes with an executable named \e[1;32mevals\e[0;33m.\n"
37
+ $stderr.puts " You invoked 'llm-evals'. While we prefer that you use 'evals' for the CLI,"
38
+ $stderr.puts " we understand that sometimes commands such as 'evals' may clash with others."
39
+ $stderr.puts " For this reason you can use \e[1;32mllm-evals\e[0;33m if you prefer moving forward.\e[0m"
40
+ $stderr.puts
41
+ $stderr.puts "This message won't be shown again, and we thank you for using llm-evals!"
13
42
 
14
- exit 0
43
+ require "io/console"
44
+
45
+ $stdin.tty? && $stdin.raw do
46
+ $stderr.print " Press any key to continue..."
47
+ waited = 0
48
+ delta = 0.1
49
+ loop do
50
+ if $stdin.wait_readable(0)
51
+ $stdin.read_nonblock(1)
52
+ break
53
+ end
54
+ sleep delta
55
+ waited += delta
56
+ if waited > 20
57
+ $stderr.puts "\n No key pressed after 20 seconds, continuing..."
58
+ break
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ # rubocop:enable Style/StderrPuts
67
+
68
+ require "llm/evals"
69
+ LLM::Evals::Launcher.new(ARGV).execute!
data/lib/.DS_Store ADDED
Binary file
data/lib/llm/.DS_Store ADDED
Binary file
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Actions
6
+ # What a suite implements to run one case: everything the model sees, and
7
+ # everything done to its answer before it is scored. An adapter never
8
+ # scores; that is the {Scoring::Scorer}'s, and keeping them apart is what lets one
9
+ # suite compare prompts and another compare models with the same runner.
10
+ #
11
+ # @abstract Subclass and override {#call}.
12
+ class Adapter
13
+ # Called once per dataset before its cases run; load what the dataset
14
+ # shares (a schema, a flow definition) here, outside the threads.
15
+ #
16
+ # @param _dataset [String] the dataset's name
17
+ # @return [void]
18
+ def prepare(_dataset) = nil
19
+
20
+ # Runs one case against one model.
21
+ #
22
+ # @param kase [Datasets::Case] the case to run
23
+ # @param model [Models::Model] the model at the setting under test
24
+ # @return [Results::Reply] with `error:` set when the model failed to answer; raise for a transport failure
25
+ # @raise [NotImplementedError] until a subclass overrides it
26
+ def call(kase, model:)
27
+ raise NotImplementedError, "#{self.class}#call(kase, model:) must return an LLM::Evals::Results::Reply"
28
+ end
29
+
30
+ # What the scorer needs to know about a case beyond the case itself: a
31
+ # schema, field types, the prompt. Passed to {Scoring::Scorer#call} as `context:`.
32
+ #
33
+ # @param _kase [Datasets::Case] the case about to be scored
34
+ # @return [Hash]
35
+ def context(_kase) = {}
36
+
37
+ # Offline checks of a dataset's shared files. `llm-evals verify` prints them.
38
+ #
39
+ # @param _dataset [String] the dataset's name
40
+ # @return [Array<String>] problems; empty when the dataset is sound
41
+ def verify(_dataset) = []
42
+
43
+ # Recorded with each Braintrust experiment, e.g. which prompt version ran.
44
+ #
45
+ # @return [Hash]
46
+ def metadata = {}
47
+ end
48
+ end
49
+ end
50
+ end