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