llm-evals 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +26 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +764 -0
  5. data/exe/evals +9 -0
  6. data/exe/llm-evals +14 -0
  7. data/lib/llm/evals/adapter.rb +47 -0
  8. data/lib/llm/evals/case.rb +36 -0
  9. data/lib/llm/evals/cli/cases.rb +48 -0
  10. data/lib/llm/evals/cli/command.rb +163 -0
  11. data/lib/llm/evals/cli/compare.rb +40 -0
  12. data/lib/llm/evals/cli/formatting.rb +16 -0
  13. data/lib/llm/evals/cli/leaderboard.rb +183 -0
  14. data/lib/llm/evals/cli/model_colors.rb +23 -0
  15. data/lib/llm/evals/cli/model_command.rb +437 -0
  16. data/lib/llm/evals/cli/progress_bars.rb +147 -0
  17. data/lib/llm/evals/cli/render.rb +37 -0
  18. data/lib/llm/evals/cli/run.rb +69 -0
  19. data/lib/llm/evals/cli/standings.rb +114 -0
  20. data/lib/llm/evals/cli/verify.rb +24 -0
  21. data/lib/llm/evals/cli/version.rb +15 -0
  22. data/lib/llm/evals/cli.rb +43 -0
  23. data/lib/llm/evals/completion.rb +11 -0
  24. data/lib/llm/evals/config.rb +198 -0
  25. data/lib/llm/evals/configuration.rb +173 -0
  26. data/lib/llm/evals/contender.rb +31 -0
  27. data/lib/llm/evals/discovery.rb +95 -0
  28. data/lib/llm/evals/experiment.rb +158 -0
  29. data/lib/llm/evals/helper.rb +98 -0
  30. data/lib/llm/evals/launcher.rb +37 -0
  31. data/lib/llm/evals/metric.rb +26 -0
  32. data/lib/llm/evals/model.rb +55 -0
  33. data/lib/llm/evals/providers/base.rb +93 -0
  34. data/lib/llm/evals/providers/catalog.rb +32 -0
  35. data/lib/llm/evals/providers/open_router.rb +64 -0
  36. data/lib/llm/evals/providers/registry.rb +112 -0
  37. data/lib/llm/evals/providers/type_safe.rb +53 -0
  38. data/lib/llm/evals/render/reports/html.rb +266 -0
  39. data/lib/llm/evals/render/reports/json.rb +172 -0
  40. data/lib/llm/evals/render/reports/pipeline.rb +153 -0
  41. data/lib/llm/evals/render/reports/terminal.rb +46 -0
  42. data/lib/llm/evals/reply.rb +18 -0
  43. data/lib/llm/evals/run_result.rb +14 -0
  44. data/lib/llm/evals/runtime.rb +74 -0
  45. data/lib/llm/evals/schema_validator.rb +84 -0
  46. data/lib/llm/evals/score.rb +26 -0
  47. data/lib/llm/evals/scorer.rb +45 -0
  48. data/lib/llm/evals/templates/html_report.css +206 -0
  49. data/lib/llm/evals/templates/html_report.html.erb +184 -0
  50. data/lib/llm/evals/templates/html_report.js +48 -0
  51. data/lib/llm/evals/testing/echo_adapter.rb +29 -0
  52. data/lib/llm/evals/testing/fake_model.rb +18 -0
  53. data/lib/llm/evals/testing/fake_provider.rb +67 -0
  54. data/lib/llm/evals/tracker.rb +80 -0
  55. data/lib/llm/evals/trackers/braintrust.rb +91 -0
  56. data/lib/llm/evals/trackers/local.rb +78 -0
  57. data/lib/llm/evals/trackers/logfire.rb +143 -0
  58. data/lib/llm/evals/trackers/registry.rb +82 -0
  59. data/lib/llm/evals/usage.rb +33 -0
  60. data/lib/llm/evals/verify.rb +49 -0
  61. data/lib/llm/evals/version.rb +7 -0
  62. data/lib/llm/evals/warnings.rb +98 -0
  63. data/lib/llm/evals/weighted_score.rb +116 -0
  64. data/lib/llm/evals.rb +83 -0
  65. metadata +274 -0
@@ -0,0 +1,184 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title><%= escape(report_title) %></title>
7
+ <%= stylesheet_tag %>
8
+ </head>
9
+ <body>
10
+ <main>
11
+ <header class="report-header" id="overview">
12
+ <div>
13
+ <p class="eyebrow">Evaluation report</p>
14
+ <h1><%= escape(report_title) %></h1>
15
+ <p class="muted"><%= escape(configuration_name) %> · generated <%= Time.now.strftime("%Y-%m-%d %H:%M %Z") %></p>
16
+ </div>
17
+ <a class="top-link" href="#overview" aria-label="Back to report overview">↑ Top</a>
18
+ </header>
19
+
20
+ <nav class="section-nav" aria-label="Report sections">
21
+ <a href="#overview">Overview</a>
22
+ <a href="#standings">Standings</a>
23
+ <a href="#coverage">Coverage</a>
24
+ <% datasets.each_with_index do |dataset, index| %>
25
+ <a href="#dataset-<%= index + 1 %>"><%= escape(dataset[:name]) %></a>
26
+ <% end %>
27
+ </nav>
28
+
29
+ <section class="health" aria-label="Run health">
30
+ <div class="stat"><b><%= health[:successful] %>/<%= health[:total] %></b><span>model calls scored</span></div>
31
+ <div class="stat"><b><%= health[:failed] %></b><span>failed calls</span></div>
32
+ <div class="stat"><b>↓ <%= escape(health[:input_tokens]) %></b><span>tokens in</span></div>
33
+ <div class="stat"><b>↑ <%= escape(health[:output_tokens]) %></b><span>tokens out</span></div>
34
+ <div class="stat"><b><%= health[:cost] %></b><span>recorded cost</span></div>
35
+ </section>
36
+ <% if health[:failed].positive? %>
37
+ <p class="notice">This run has failed or unscored cases. A global leader requires full, error-free coverage.</p>
38
+ <% end %>
39
+
40
+ <section id="standings">
41
+ <h2>Global standings across all cases in this run</h2>
42
+ <p class="muted"><%= escape(weight_description) %></p>
43
+
44
+ <% if standings[:leader] %>
45
+ <% leader = standings[:leader] %>
46
+ <% leader_provider, leader_model = model_parts(leader[:model]) %>
47
+ <div class="leader">
48
+ <span class="eyebrow">ALL-CASE LEADER</span>
49
+ <strong><span class="provider"><%= leader_provider %></span>/<span class="model-id"><%= leader_model %></span></strong>
50
+ <span><%= percent(ranking_value(leader)) %> · Effort <%= effort_label(leader[:model]) %> · Temperature <%= temperature_label(leader[:model]) %> · <%= leader[:successful] %>/<%= standings[:expected_count] %> cases · <%= money(leader[:cost]) %></span>
51
+ </div>
52
+ <% else %>
53
+ <% best_coverage = standings[:best_coverage] %>
54
+ <div class="leader none">
55
+ <span class="eyebrow">ALL-CASE LEADER</span>
56
+ <strong>No leader established</strong>
57
+ <% if best_coverage %>
58
+ <% best_provider, best_model = model_parts(best_coverage[:model]) %>
59
+ <span>No contender covered all <%= standings[:expected_count] %> cases without failures. Best coverage: <%= best_coverage[:successful] %>/<%= standings[:expected_count] %> from <span class="provider"><%= best_provider %></span>/<span class="model-id"><%= best_model %></span> (effort <%= effort_label(best_coverage[:model]) %>, temperature <%= temperature_label(best_coverage[:model]) %>).</span>
60
+ <% else %>
61
+ <span>No model results are available for ranking.</span>
62
+ <% end %>
63
+ </div>
64
+ <% end %>
65
+
66
+ <% if standings[:ranked].empty? %>
67
+ <p class="muted">No fully covered contenders to plot.</p>
68
+ <% else %>
69
+ <div class="scroll">
70
+ <table class="sortable standings-table" aria-label="Model standings, initially ordered highest score first">
71
+ <thead>
72
+ <tr>
73
+ <th><span>Provider</span><span>(vendor)</span></th>
74
+ <th><span>Model</span><span>(ID and settings)</span></th>
75
+ <th><span>Weighted</span><span>score</span></th>
76
+ <th><span>Primary score</span><span>(<%= escape(primary_metric) %>)</span></th>
77
+ <th title="Input tokens"><span>↓ Tokens in</span><span>(input)</span></th>
78
+ <th title="Output tokens"><span>↑ Tokens out</span><span>(output)</span></th>
79
+ <th class="standings-bar"><span>Score</span><span>bar</span></th>
80
+ </tr>
81
+ </thead>
82
+ <tbody>
83
+ <% standings[:ranked].reverse_each do |entry| %>
84
+ <% provider, model = model_parts(entry[:model]) %>
85
+ <% ranking = ranking_value(entry).to_f %>
86
+ <% weighted_label = weighted_score ? percent(ranking_value(entry)) : "—" %>
87
+ <tr>
88
+ <td><span class="provider"><%= provider %></span></td><td><span class="model-id"><%= model %></span> <span class="standings-settings">(<%= effort_label(entry[:model]) %>, t=<%= temperature_label(entry[:model]) %>)</span></td>
89
+ <td><%= weighted_label %></td><td><%= percent(entry[:row][primary_metric]) %></td>
90
+ <td><%= escape(tokens(entry[:input_tokens])) %></td><td><%= escape(tokens(entry[:output_tokens])) %></td>
91
+ <td class="standings-bar"><div class="track" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="<%= (ranking * 100).round(1) %>"><i style="width:<%= (ranking * 100).clamp(0, 100) %>%"></i></div></td>
92
+ </tr>
93
+ <% end %>
94
+ </tbody>
95
+ </table>
96
+ </div>
97
+ <% end %>
98
+
99
+ <h3 id="coverage">Coverage by model</h3>
100
+ <div class="scroll">
101
+ <table class="sortable">
102
+ <thead><tr><th>Provider</th><th>Model</th><th>Effort</th><th>Temperature</th><th>Scored</th><th>Weighted score</th><th>Errors</th><th>Cost</th></tr></thead>
103
+ <tbody>
104
+ <% standings[:entries].sort_by { [ranking_value(it).nil? ? 1 : 0, -(ranking_value(it) || 0), -it[:successful], it[:errors]] }.each do |entry| %>
105
+ <% provider, model = model_parts(entry[:model]) %>
106
+ <% score = entry[:complete] && ranking_value(entry) ? percent(ranking_value(entry)) : "Not rankable" %>
107
+ <tr>
108
+ <td><span class="provider"><%= provider %></span></td><td><span class="model-id"><%= model %></span></td>
109
+ <td><%= effort_label(entry[:model]) %></td><td><%= temperature_label(entry[:model]) %></td>
110
+ <td><%= entry[:successful] %>/<%= entry[:total] %></td><td><%= score %></td>
111
+ <td><%= escape(entry[:errors]) %></td><td><%= money(entry[:cost]) %></td>
112
+ </tr>
113
+ <% end %>
114
+ </tbody>
115
+ </table>
116
+ </div>
117
+ </section>
118
+
119
+ <% datasets.each_with_index do |dataset, index| %>
120
+ <% winner = dataset[:winner] %>
121
+ <% summary = if winner
122
+ provider, model = model_parts(winner[:model])
123
+ "Leader: #{provider}/#{model} · effort #{effort_label(winner[:model])} · temperature #{temperature_label(winner[:model])} · #{percent(ranking_value(winner))}"
124
+ else
125
+ "No complete contender"
126
+ end %>
127
+ <details class="dataset" id="dataset-<%= index + 1 %>" open>
128
+ <summary><strong><%= escape(dataset[:name]) %></strong><span><%= summary %> · <%= dataset[:case_keys].length %> cases</span></summary>
129
+ <div class="dataset-content">
130
+ <h3>Model scores</h3>
131
+ <div class="scroll">
132
+ <table class="sortable">
133
+ <thead><tr><th>Provider</th><th>Model</th><th>Effort</th><th>Temperature</th><th>Weighted score</th><th>Scored</th><th>Errors</th><th title="Input tokens">↓ Tokens in</th><th title="Output tokens">↑ Tokens out</th><th>Cost</th></tr></thead>
134
+ <tbody>
135
+ <% dataset[:entries].each do |entry| %>
136
+ <% provider, model = model_parts(entry[:model]) %>
137
+ <% score = entry[:complete] ? percent(ranking_value(entry)) : "Not rankable" %>
138
+ <tr>
139
+ <td><span class="provider"><%= provider %></span></td><td><span class="model-id"><%= model %></span></td>
140
+ <td><%= effort_label(entry[:model]) %></td><td><%= temperature_label(entry[:model]) %></td>
141
+ <td><%= score %></td><td><%= entry[:successful] %>/<%= entry[:total] %></td>
142
+ <td><%= escape(entry[:errors]) %></td><td><%= escape(tokens(entry[:input_tokens])) %></td><td><%= escape(tokens(entry[:output_tokens])) %></td><td><%= money(entry[:cost]) %></td>
143
+ </tr>
144
+ <% end %>
145
+ </tbody>
146
+ </table>
147
+ </div>
148
+ <h3>Cases</h3>
149
+ <% dataset[:cases].each do |case_result| %>
150
+ <% kase = case_result.case %>
151
+ <% runs = case_results(kase, dataset[:summaries]) %>
152
+ <details class="case">
153
+ <summary><strong><%= escape(kase.id) %></strong><span><%= escape(kase.variant) %> · difficulty <%= escape(kase.difficulty) %></span></summary>
154
+ <div class="case-content">
155
+ <details>
156
+ <summary>Input and expected answer</summary>
157
+ <h4>Input</h4><pre><%= escape(kase.input) %></pre>
158
+ <h4>Expected</h4><pre><%= escape(kase.expected.nil? ? "No expected answer" : pretty(kase.expected)) %></pre>
159
+ </details>
160
+ <div class="scroll">
161
+ <table class="sortable">
162
+ <thead><tr><th>Provider</th><th>Model</th><th>Effort</th><th>Temperature</th><th>Score / status</th><th>Diagnostics</th></tr></thead>
163
+ <tbody>
164
+ <% runs.each do |run_result| %>
165
+ <% provider, model = model_parts(run_result[:run].experiment.model) %>
166
+ <tr>
167
+ <td><span class="provider"><%= provider %></span></td><td><span class="model-id"><%= model %></span></td>
168
+ <td><%= effort_label(run_result[:run].experiment.model) %></td><td><%= temperature_label(run_result[:run].experiment.model) %></td>
169
+ <td><%= escape(run_result[:status]) %></td><td><pre><%= escape(pretty(run_result[:details])) %></pre></td>
170
+ </tr>
171
+ <% end %>
172
+ </tbody>
173
+ </table>
174
+ </div>
175
+ </div>
176
+ </details>
177
+ <% end %>
178
+ </div>
179
+ </details>
180
+ <% end %>
181
+ </main>
182
+ <%= script_tag %>
183
+ </body>
184
+ </html>
@@ -0,0 +1,48 @@
1
+ document.querySelectorAll("table.sortable").forEach((table) => {
2
+ const headers = Array.from(table.querySelectorAll("thead th"));
3
+ const body = table.tBodies[0];
4
+
5
+ headers.forEach((header, index) => {
6
+ header.tabIndex = 0;
7
+ header.setAttribute("aria-sort", "none");
8
+
9
+ const sort = () => {
10
+ const ascending = header.dataset.direction !== "ascending";
11
+
12
+ headers.forEach((item) => {
13
+ item.dataset.direction = "";
14
+ item.setAttribute("aria-sort", "none");
15
+ });
16
+
17
+ header.dataset.direction = ascending ? "ascending" : "descending";
18
+ header.setAttribute("aria-sort", header.dataset.direction);
19
+
20
+ const rows = Array.from(body.rows);
21
+ rows.sort((left, right) => {
22
+ const first = left.cells[index]?.textContent.trim() ?? "";
23
+ const second = right.cells[index]?.textContent.trim() ?? "";
24
+ const number = (value) => {
25
+ const normalized = value.replace(/[,$%]/g, "");
26
+ return /^-?\d+(?:\.\d+)?$/.test(normalized) ? Number(normalized) : null;
27
+ };
28
+ const firstNumber = number(first);
29
+ const secondNumber = number(second);
30
+ const comparison = firstNumber !== null && secondNumber !== null
31
+ ? firstNumber - secondNumber
32
+ : first.localeCompare(second, undefined, { numeric: true, sensitivity: "base" });
33
+
34
+ return ascending ? comparison : -comparison;
35
+ });
36
+
37
+ rows.forEach((row) => body.append(row));
38
+ };
39
+
40
+ header.addEventListener("click", sort);
41
+ header.addEventListener("keydown", (event) => {
42
+ if (event.key === "Enter" || event.key === " ") {
43
+ event.preventDefault();
44
+ sort();
45
+ }
46
+ });
47
+ });
48
+ });
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Testing
6
+ # The smallest honest adapter: sends the case's input as the user
7
+ # prompt, parses the reply as a JSON object, and reports a reply that
8
+ # is not one as the model's failure. The toy suite runs on it, and a
9
+ # new suite can start from it.
10
+ class EchoAdapter < Adapter
11
+ SYSTEM = "Answer with a single JSON object and nothing else."
12
+ FENCE = /\A```(?:json)?\s*|\s*```\z/
13
+
14
+ # @param kase [Case]
15
+ # @param model [Model]
16
+ # @return [Reply]
17
+ def call(kase, model:)
18
+ completion = model.complete(system: SYSTEM, user: kase.input, json: true)
19
+ output = JSON.parse(completion.text.strip.gsub(FENCE, ""))
20
+ return Reply.new(error: "reply is not a JSON object", raw: completion.text, usage: completion.usage) unless output.is_a?(Hash)
21
+
22
+ Reply.new(output: output, raw: completion.text, usage: completion.usage)
23
+ rescue JSON::ParserError
24
+ Reply.new(error: "reply is not JSON", raw: completion.text, usage: completion.usage)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Testing
6
+ # A {Model} on a {FakeProvider} of its own, for specs that run an
7
+ # adapter or an {Experiment} without the CLI.
8
+ class FakeModel < Model
9
+ # @param id [String]
10
+ # @param settings [Hash]
11
+ # @param provider_options [Hash] passed to {FakeProvider#initialize}
12
+ def initialize(id: "fake/echo", settings: { effort: nil, temperature: 0.2, max_tokens: 4096 }, **provider_options)
13
+ super(id: id, provider: FakeProvider.new(**provider_options), settings: settings)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Testing
6
+ # Serves every "fake/..." model without a network: a call answers with
7
+ # the expected JSON of the case whose input matches the user prompt, so
8
+ # a suite's own plumbing can be run end to end through the executable.
9
+ #
10
+ # `junk` names cases (by id or key) that get a non-JSON reply, and
11
+ # `wrong` maps a case to fields to answer wrongly. `answers`, a callable
12
+ # taking the user prompt, replaces the lookup altogether.
13
+ class FakeProvider < Providers::Base
14
+ PREFIX = "fake/"
15
+ USAGE = Usage.new(input_tokens: 100, output_tokens: 10, cost_usd: 0.001)
16
+ JUNK = "Sure! Here is my answer, in prose rather than JSON."
17
+
18
+ attr_reader :calls
19
+
20
+ # @param junk [Array<String>] case ids or keys to answer with prose
21
+ # @param wrong [Hash{String => Hash}] case id or key => fields to override
22
+ # @param answers [#call, nil] user prompt => reply text; replaces the lookup
23
+ # @param discovery [Discovery, nil] where to look cases up; the configured suite's when nil
24
+ def initialize(junk: [], wrong: {}, answers: nil, discovery: nil)
25
+ super()
26
+ @junk = junk
27
+ @wrong = wrong
28
+ @answers = answers
29
+ @discovery = discovery
30
+ @calls = []
31
+ @lock = Mutex.new
32
+ end
33
+
34
+ def name = "fake"
35
+ def request_type = :prompt
36
+ def prefix = PREFIX
37
+ def env_keys = []
38
+
39
+ # @return [nil] any id passes verification
40
+ def catalog = nil
41
+
42
+ # @param model [Model]
43
+ # @param system [String, nil]
44
+ # @param user [String]
45
+ # @param json [Boolean]
46
+ # @return [Completion]
47
+ def complete(model, system:, user:, json: true)
48
+ @lock.synchronize { @calls << { model: model.id, system: system, user: user, json: json } }
49
+ Completion.new(text: @answers ? @answers.call(user) : answer(user), usage: USAGE)
50
+ end
51
+
52
+ private
53
+
54
+ def answer(user)
55
+ kase = discovery.all.find { it.input == user }
56
+ return "{}" if kase.nil?
57
+ return JUNK if @junk.include?(kase.id) || @junk.include?(kase.key)
58
+
59
+ wrong = @wrong[kase.id] || @wrong[kase.key] || {}
60
+ JSON.generate(kase.expected.is_a?(Hash) ? kase.expected.merge(wrong) : kase.expected)
61
+ end
62
+
63
+ def discovery = @discovery || LLM::Evals.configuration.discovery
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # Where a run's results go besides the terminal. The gem always owns the
6
+ # run loop; a tracker only receives callbacks: {#start} once per
7
+ # experiment, {#record} once per finished case, {#finish} once with the
8
+ # summary. The local JSON log ({Trackers::Local}) always runs; at most
9
+ # one remote tracker runs beside it, chosen by `--tracker`, the
10
+ # `tracker:` key, or the environment (see {Trackers::Registry#choose}).
11
+ #
12
+ # A tracker for another framework (W&B Weave, an OTLP backend) subclasses
13
+ # this in a consumer or a plugin gem and is registered with `c.tracker`.
14
+ #
15
+ # @abstract Override the three callbacks; declare {.env_key} and {.gems}.
16
+ class Tracker
17
+ class << self
18
+ # @return [String] the name `--tracker` and `tracker:` use; the class name, dasherized, by default
19
+ def tracker_name = (name || "Tracker").split("::").last.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
20
+
21
+ # @return [String, nil] the environment variable whose presence selects this tracker when nothing else does
22
+ def env_key = nil
23
+
24
+ # @return [Array<String>] the optional gems this tracker requires when chosen
25
+ def gems = []
26
+
27
+ # @param env [Hash] the environment
28
+ # @return [Boolean] whether the environment names this tracker
29
+ def detect?(env = ENV) = !env_key.nil? && !env[env_key].to_s.strip.empty?
30
+
31
+ # Loads the gems and checks the key, with an error that names both, so
32
+ # a chosen tracker that cannot run says so before any model is called.
33
+ #
34
+ # @param env [Hash]
35
+ # @return [void]
36
+ # @raise [MissingDependency, Error]
37
+ def requirements!(env = ENV)
38
+ gems.each do |gem_name|
39
+ LLM::Evals.optional!(gem_name.tr("-", "/"), gem_name, needed_for: "the #{tracker_name} tracker")
40
+ end
41
+ return if env_key.nil? || detect?(env)
42
+
43
+ raise Error, "the #{tracker_name} tracker needs #{env_key} in the environment (gems: #{gems.join(", ")})"
44
+ end
45
+ end
46
+
47
+ attr_reader :context
48
+
49
+ # @param context [Hash] `suite:`, `project:`, `project_id:` and `options:` (the tracker's own key in the config file)
50
+ def initialize(**context)
51
+ @context = context
52
+ end
53
+
54
+ # @return [String]
55
+ def name = self.class.tracker_name
56
+
57
+ # @return [Boolean] false turns every callback into a no-op
58
+ def enabled? = true
59
+
60
+ # @return [String] one line for `--verbose`: where this tracker sends things, and why
61
+ def describe = name
62
+
63
+ # @param experiment [Experiment]
64
+ # @return [void]
65
+ def start(_experiment) = nil
66
+
67
+ # @param result [RunResult]
68
+ # @return [void]
69
+ def record(_result) = nil
70
+
71
+ # @param experiment [Experiment]
72
+ # @param summary [Experiment::Summary]
73
+ # @return [Hash] `url:` to the experiment when one is known, else nil
74
+ def finish(_experiment, _summary) = { url: nil }
75
+
76
+ # @return [Hash] the tracker's own section of the config file
77
+ def options = context.fetch(:options, nil) || {}
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Trackers
6
+ # Records each case as a Braintrust eval row in an experiment of the
7
+ # suite's project, through the braintrust gem's own OpenTelemetry
8
+ # exporter, the way `Braintrust::Eval.run` would have, except that the
9
+ # loop stays ours. The gem is required only when this tracker is chosen.
10
+ class Braintrust < Tracker
11
+ def self.env_key = "BRAINTRUST_API_KEY"
12
+ def self.gems = ["braintrust"]
13
+
14
+ attr_reader :experiment_id, :permalink
15
+
16
+ def describe = "braintrust: project #{context[:project_id] || context[:project].inspect}"
17
+
18
+ # Logs in, finds or creates the project, creates the experiment.
19
+ #
20
+ # @param experiment [Experiment]
21
+ # @return [void]
22
+ def start(experiment)
23
+ ::Braintrust.init(blocking_login: true, auto_instrument: false)
24
+ @state = ::Braintrust.current_state
25
+ @state.login
26
+ project_id = context[:project_id] || ::Braintrust::API::Internal::Projects.new(@state).create(name: context[:project]).fetch("id")
27
+ @experiment_id = ::Braintrust::API::Internal::Experiments.new(@state).create(
28
+ name: experiment.name,
29
+ project_id: project_id,
30
+ ensure_new: true,
31
+ tags: [context[:suite], experiment.model.id, "dataset-#{experiment.dataset}"].compact,
32
+ metadata: metadata(experiment)
33
+ ).fetch("id")
34
+ @permalink = @state.object_permalink(object_type: "experiment", object_id: experiment_id)
35
+ # Braintrust.init installs its exporter on the global provider, which
36
+ # is where the gem's own runner takes its tracer from too.
37
+ @tracer = OpenTelemetry.tracer_provider.tracer("llm-evals")
38
+ end
39
+
40
+ # One root "eval" span per case, with the shape Braintrust's own
41
+ # runner emits: input, expected, output, scores and metadata as JSON
42
+ # attributes, parented to the experiment.
43
+ #
44
+ # @param result [RunResult]
45
+ # @return [void]
46
+ def record(result)
47
+ span = @tracer.start_root_span("eval")
48
+ span.set_attribute("braintrust.parent", "experiment_id:#{experiment_id}")
49
+ json(span, "braintrust.span_attributes", { type: "eval", name: result.experiment.name })
50
+ json(span, "braintrust.input_json", { case: result.case.key, text: result.case.input })
51
+ json(span, "braintrust.expected_json", result.case.expected) unless result.case.expected.nil?
52
+ json(span, "braintrust.metadata", case_metadata(result))
53
+ span.set_attribute("braintrust.tags", ["variant-#{result.case.variant}", "dataset-#{result.case.dataset}"])
54
+ if result.failed?
55
+ span.status = OpenTelemetry::Trace::Status.error(result.error)
56
+ else
57
+ json(span, "braintrust.output_json", result.reply.error? ? { "_error" => result.reply.error } : result.reply.output)
58
+ json(span, "braintrust.scores", result.score.metrics.compact)
59
+ end
60
+ ensure
61
+ span&.finish
62
+ end
63
+
64
+ # Flushes the spans and hands back the experiment's page.
65
+ #
66
+ # @return [Hash] `url:` the experiment permalink
67
+ def finish(_experiment, _summary)
68
+ provider = OpenTelemetry.tracer_provider
69
+ provider.force_flush if provider.respond_to?(:force_flush)
70
+ { url: permalink }
71
+ end
72
+
73
+ private
74
+
75
+ def json(span, key, value) = span.set_attribute(key, JSON.dump(value))
76
+
77
+ def metadata(experiment)
78
+ { model: experiment.model.id, provider: experiment.model.provider.name, dataset: experiment.dataset,
79
+ **experiment.model.settings, **experiment.adapter.metadata }
80
+ end
81
+
82
+ def case_metadata(result)
83
+ base = { case: result.case.key, dataset: result.case.dataset, variant: result.case.variant, difficulty: result.case.difficulty }
84
+ return base.merge(error: result.error) if result.failed?
85
+
86
+ base.merge(diagnostics: result.score.diagnostics, outcomes: result.score.outcomes, usage: result.reply.usage.to_h)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module LLM
6
+ module Evals
7
+ module Trackers
8
+ # Always on: every experiment's results and summary land in one JSON
9
+ # file under `log/`, so a run is never lost to a closed terminal or a
10
+ # remote tracker that was never configured.
11
+ class Local < Tracker
12
+ attr_reader :dir, :path
13
+
14
+ # @param dir [String] where the files go; `log/llm-evals` at the repository root by default
15
+ # @param context [Hash] see {Tracker#initialize}
16
+ def initialize(dir: File.join(Dry::CLI::UI::Invocation.root(Dir.pwd), "log", "llm-evals"), **context)
17
+ super(**context)
18
+ @dir = dir
19
+ @results = []
20
+ @lock = Mutex.new
21
+ end
22
+
23
+ def describe = "local: #{dir}"
24
+
25
+ def start(experiment)
26
+ @started_at = Time.now
27
+ @path = File.join(dir, "#{slug(experiment.name)}.#{@started_at.strftime("%Y-%m-%d.%H%M%S")}.json")
28
+ end
29
+
30
+ def record(result)
31
+ @lock.synchronize { @results << result }
32
+ end
33
+
34
+ # @return [Hash] `url:` nil, `path:` the file written
35
+ def finish(experiment, summary)
36
+ FileUtils.mkdir_p(dir)
37
+ File.write(path, JSON.pretty_generate(document(experiment, summary)))
38
+ { url: nil, path: path }
39
+ end
40
+
41
+ private
42
+
43
+ def slug(text) = text.gsub(/[^A-Za-z0-9.-]+/, "_").squeeze("_").delete_prefix("_").delete_suffix("_")
44
+
45
+ def document(experiment, summary)
46
+ {
47
+ suite: context[:suite],
48
+ experiment: experiment.name,
49
+ model: experiment.model.id,
50
+ provider: experiment.model.provider.name,
51
+ settings: experiment.model.settings,
52
+ dataset: experiment.dataset,
53
+ started_at: @started_at.iso8601,
54
+ duration: summary.duration,
55
+ means: summary.means,
56
+ errors: summary.errors,
57
+ usage: summary.usage.to_h,
58
+ results: @lock.synchronize { @results.sort_by { experiment.cases.index(it.case) }.map { row(it) } }
59
+ }
60
+ end
61
+
62
+ def row(result)
63
+ {
64
+ case: result.case.key,
65
+ duration: result.duration,
66
+ error: result.error,
67
+ score: result.score&.value,
68
+ metrics: result.score&.metrics,
69
+ outcomes: result.score&.outcomes,
70
+ diagnostics: result.score&.diagnostics,
71
+ reply_error: result.reply&.error,
72
+ usage: result.reply&.usage&.to_h
73
+ }
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end