agent-evals 0.1.1
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 +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +635 -0
- data/exe/agent-evals +9 -0
- data/lib/agent/evals/adapter.rb +47 -0
- data/lib/agent/evals/case.rb +36 -0
- data/lib/agent/evals/cli/cases.rb +48 -0
- data/lib/agent/evals/cli/command.rb +163 -0
- data/lib/agent/evals/cli/compare.rb +33 -0
- data/lib/agent/evals/cli/model_command.rb +393 -0
- data/lib/agent/evals/cli/progress_bars.rb +90 -0
- data/lib/agent/evals/cli/run.rb +61 -0
- data/lib/agent/evals/cli/verify.rb +24 -0
- data/lib/agent/evals/cli/version.rb +15 -0
- data/lib/agent/evals/cli.rb +40 -0
- data/lib/agent/evals/completion.rb +11 -0
- data/lib/agent/evals/config.rb +146 -0
- data/lib/agent/evals/configuration.rb +172 -0
- data/lib/agent/evals/contender.rb +31 -0
- data/lib/agent/evals/discovery.rb +95 -0
- data/lib/agent/evals/experiment.rb +133 -0
- data/lib/agent/evals/helper.rb +98 -0
- data/lib/agent/evals/launcher.rb +37 -0
- data/lib/agent/evals/metric.rb +26 -0
- data/lib/agent/evals/model.rb +55 -0
- data/lib/agent/evals/providers/base.rb +93 -0
- data/lib/agent/evals/providers/catalog.rb +32 -0
- data/lib/agent/evals/providers/open_router.rb +64 -0
- data/lib/agent/evals/providers/registry.rb +112 -0
- data/lib/agent/evals/providers/type_safe.rb +53 -0
- data/lib/agent/evals/reply.rb +18 -0
- data/lib/agent/evals/run_result.rb +14 -0
- data/lib/agent/evals/runtime.rb +74 -0
- data/lib/agent/evals/schema_validator.rb +84 -0
- data/lib/agent/evals/score.rb +26 -0
- data/lib/agent/evals/scorer.rb +45 -0
- data/lib/agent/evals/testing/echo_adapter.rb +29 -0
- data/lib/agent/evals/testing/fake_model.rb +18 -0
- data/lib/agent/evals/testing/fake_provider.rb +67 -0
- data/lib/agent/evals/tracker.rb +80 -0
- data/lib/agent/evals/trackers/braintrust.rb +91 -0
- data/lib/agent/evals/trackers/local.rb +78 -0
- data/lib/agent/evals/trackers/logfire.rb +143 -0
- data/lib/agent/evals/trackers/registry.rb +82 -0
- data/lib/agent/evals/usage.rb +33 -0
- data/lib/agent/evals/verify.rb +49 -0
- data/lib/agent/evals/version.rb +7 -0
- data/lib/agent/evals/warnings.rb +89 -0
- data/lib/agent/evals.rb +82 -0
- metadata +257 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Agent
|
|
7
|
+
module Evals
|
|
8
|
+
module CLI
|
|
9
|
+
# The options shared by the commands that call a model. Settings resolve
|
|
10
|
+
# flag first, then the config entry, then .agent-eval.config.yml's defaults.
|
|
11
|
+
#
|
|
12
|
+
# Every column that is not a metric is the gem's; the metric columns,
|
|
13
|
+
# their glossary and the ranking come from the suite's {Scorer#metrics}.
|
|
14
|
+
class ModelCommand < Command
|
|
15
|
+
# How close to the best primary metric a model must come to compete on
|
|
16
|
+
# price: 0.02 is two percentage points.
|
|
17
|
+
VALUE_MARGIN = 0.02
|
|
18
|
+
|
|
19
|
+
# What the gem's own leaderboard columns mean, printed under it with the metrics'.
|
|
20
|
+
GLOSSARY = {
|
|
21
|
+
"effort" => "Reasoning effort sent to the model. \"default\" sends none, and the model decides; " \
|
|
22
|
+
"a provider that takes none shows \"default\" too.",
|
|
23
|
+
"temp" => "Sampling temperature sent to the model. \"default\" sends none.",
|
|
24
|
+
"errors" => "Cases whose call failed outright (an API or network error), so they have no scores at all. " \
|
|
25
|
+
"A reply the model got wrong is not an error; it scores 0.",
|
|
26
|
+
"time" => "Wall-clock time for the experiment. Experiments run side by side and share the network, " \
|
|
27
|
+
"so compare times within one run only.",
|
|
28
|
+
"tok_in" => "Input tokens sent, summed over the cases, as the provider counted them.",
|
|
29
|
+
"tok_out" => "Output tokens received, summed over the cases; reasoning tokens are included where the " \
|
|
30
|
+
"provider bills them as output.",
|
|
31
|
+
"cost" => "US dollars, as the provider reported them, summed over the cases.",
|
|
32
|
+
"score" => "The mean of each case's overall score, as the scorer computed it. The last table ranks " \
|
|
33
|
+
"every experiment by it."
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
def self.inherited(subclass)
|
|
37
|
+
super
|
|
38
|
+
subclass.extend(Dry::CLI::UI::Flags)
|
|
39
|
+
subclass.flags(:dry_run, :output)
|
|
40
|
+
subclass.option :models,
|
|
41
|
+
type: :array,
|
|
42
|
+
aliases: ["-m"],
|
|
43
|
+
default: [],
|
|
44
|
+
desc: "Model ids, comma-separated, instead of the config file's vendors"
|
|
45
|
+
subclass.option :dataset,
|
|
46
|
+
type: :array,
|
|
47
|
+
aliases: ["-d"],
|
|
48
|
+
default: [],
|
|
49
|
+
desc: "Datasets, comma-separated; every dataset when omitted. Each model runs on each"
|
|
50
|
+
subclass.option :config, desc: "The lineup file (default: the suite's .agent-eval..agent-eval.config.yml)"
|
|
51
|
+
subclass.option :effort, aliases: ["-e"], desc: "Reasoning effort: default, or one the provider accepts (overrides the config file)"
|
|
52
|
+
subclass.option :temperature, aliases: ["-t"], desc: "Sampling temperature, or 'default' to send none (overrides the config file)"
|
|
53
|
+
subclass.option :max_tokens, desc: "Output token cap, reasoning included (overrides the config file)"
|
|
54
|
+
subclass.option :case,
|
|
55
|
+
type: :array,
|
|
56
|
+
aliases: ["-c"],
|
|
57
|
+
default: [],
|
|
58
|
+
desc: "Only these cases, by id prefix: -c clear or -c clear/003,vague/001"
|
|
59
|
+
subclass.option :parallelism, aliases: ["-p"], desc: "Cases in flight at once, per model (default: the config's defaults.parallelism)"
|
|
60
|
+
subclass.option :project,
|
|
61
|
+
desc: "Braintrust project name; default is the BRAINTRUST_PROJECT_ID project, else the config file's project"
|
|
62
|
+
subclass.option :open,
|
|
63
|
+
type: :boolean,
|
|
64
|
+
default: true,
|
|
65
|
+
desc: "Open the best experiment in the browser when done (no effect with --local)"
|
|
66
|
+
subclass.option :local,
|
|
67
|
+
type: :boolean,
|
|
68
|
+
default: false,
|
|
69
|
+
desc: "No remote tracker: results go to log/ only (still calls the models)"
|
|
70
|
+
subclass.option :tracker,
|
|
71
|
+
desc: "The remote tracker: braintrust, logfire, none, or one the suite registers " \
|
|
72
|
+
"(default: the config's tracker:, else the one whose key is in the environment)"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# @return [Config] the lineup: --config, else the suite's
|
|
78
|
+
def lineup(options) = options[:config] ? Config.new(File.expand_path(options[:config])) : configuration.lineup
|
|
79
|
+
|
|
80
|
+
# @return [Providers::Registry]
|
|
81
|
+
def providers = configuration.providers
|
|
82
|
+
|
|
83
|
+
# @return [Scorer]
|
|
84
|
+
def scorer = configuration.scorer
|
|
85
|
+
|
|
86
|
+
# @return [Array<Metric>]
|
|
87
|
+
def metrics = scorer.metrics
|
|
88
|
+
|
|
89
|
+
# @return [Hash{String => Object}] the setting flags that were given
|
|
90
|
+
def overrides(options)
|
|
91
|
+
{ "effort" => options[:effort], "temperature" => options[:temperature], "max_tokens" => options[:max_tokens] }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def experiment(contender, config, options, dataset:)
|
|
95
|
+
Experiment.new(
|
|
96
|
+
model: providers.model_for(contender),
|
|
97
|
+
cases: cases_for(options[:case], dataset: dataset),
|
|
98
|
+
adapter: configuration.adapter,
|
|
99
|
+
scorer: scorer,
|
|
100
|
+
parallelism: Integer(options[:parallelism] || config.defaults["parallelism"]),
|
|
101
|
+
trackers: trackers_for(config, options)
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The local JSON log, always, then the one remote tracker if any,
|
|
106
|
+
# each experiment getting instances of its own.
|
|
107
|
+
#
|
|
108
|
+
# @return [Array<Tracker>]
|
|
109
|
+
def trackers_for(config, options)
|
|
110
|
+
context = { suite: configuration.name,
|
|
111
|
+
project: options[:project] || config.project || configuration.project || configuration.name,
|
|
112
|
+
project_id: options[:project] ? nil : ENV["BRAINTRUST_PROJECT_ID"].to_s[/\S+/] }
|
|
113
|
+
local = Trackers::Local.new(dir: File.join(Dry::CLI::UI::Invocation.root(Dir.pwd), "log", "agent-evals"), **context)
|
|
114
|
+
klass = remote_tracker(config, options)
|
|
115
|
+
remote = klass&.new(**context, options: config.tracker_options(klass.tracker_name))
|
|
116
|
+
[local, remote].compact
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The remote tracker class this run uses, chosen once: --tracker,
|
|
120
|
+
# then tracker: in the config, then the environment. --local is none.
|
|
121
|
+
#
|
|
122
|
+
# @return [Class<Tracker>, nil]
|
|
123
|
+
def remote_tracker(config, options)
|
|
124
|
+
return @remote_tracker if defined?(@remote_tracker)
|
|
125
|
+
|
|
126
|
+
chosen = configuration.trackers.choose(flag: options[:tracker], config: config.tracker, env: ENV, local: options[:local])
|
|
127
|
+
@remote_tracker = chosen&.first
|
|
128
|
+
err.puts "tracker: #{chosen ? "#{chosen.first.tracker_name}, because #{chosen.last}" : "none"}" if options[:verbose]
|
|
129
|
+
@remote_tracker
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The contenders a command runs: the models named on the command line,
|
|
133
|
+
# or every model .agent-eval.config.yml lists.
|
|
134
|
+
#
|
|
135
|
+
# @param models [Array<String>] as given to -m, possibly comma-separated
|
|
136
|
+
# @return [Array<Contender>]
|
|
137
|
+
# @raise [Error] when neither names any model
|
|
138
|
+
def contenders(config, models, options)
|
|
139
|
+
models = models.flat_map { it.split(",") }
|
|
140
|
+
found = models.empty? ? config.contenders(overrides(options)) : config.contenders_for(models, overrides(options))
|
|
141
|
+
raise Error, "no models: list some under vendors in #{config.path}, or pass -m" if found.empty?
|
|
142
|
+
|
|
143
|
+
admit(found, config)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Keeps the contenders whose provider produces what the suite declares
|
|
147
|
+
# in `request_types`, and records each refusal in {#warnings} by name,
|
|
148
|
+
# so a refused model is reported rather than run, failed and billed.
|
|
149
|
+
#
|
|
150
|
+
# @return [Array<Contender>] the admitted contenders
|
|
151
|
+
# @raise [Error] when none is admitted, naming every refusal
|
|
152
|
+
def admit(found, config)
|
|
153
|
+
found.each { providers.for(it) } # an unserved id fails here, before anything is spent
|
|
154
|
+
admitted, refusals = providers.admit(found, request_types: config.request_types)
|
|
155
|
+
refusals.each { warnings.record(it.to_s) }
|
|
156
|
+
raise Error, "no admitted models: #{refusals.join("; ")}" if admitted.empty?
|
|
157
|
+
|
|
158
|
+
admitted
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Env, the tracker's gems and key, hooks, provider clients, then the catalogs.
|
|
162
|
+
def setup!(contenders, options)
|
|
163
|
+
configuration.runtime.setup!(providers: providers.used_by(contenders), tracker: remote_tracker(lineup(options), options))
|
|
164
|
+
providers.verify!(contenders)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# What -n prints: every experiment that would run, and where the
|
|
168
|
+
# refusals went, if there were any.
|
|
169
|
+
def matrix(datasets, contenders)
|
|
170
|
+
rows = datasets.product(contenders).map { |name, c| [name, c.model, c.effort, c.temperature, c.max_tokens] }
|
|
171
|
+
table(rows, header: %w[dataset model effort temp max_tokens])
|
|
172
|
+
save_warnings
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Runs the experiments side by side, a progress bar each. What would
|
|
176
|
+
# print as a warning meanwhile, failed cases included, goes to
|
|
177
|
+
# {#warnings} instead.
|
|
178
|
+
#
|
|
179
|
+
# @param runs [Array<Experiment>]
|
|
180
|
+
# @param concurrency [Integer] experiments at once; 0 runs them all together
|
|
181
|
+
# @return [Array<Experiment::Summary>]
|
|
182
|
+
def execute(runs, concurrency: 0)
|
|
183
|
+
summaries = Array.new(runs.size)
|
|
184
|
+
warnings.capture { bars(runs, summaries, concurrency) }
|
|
185
|
+
summaries.compact.each { |s| s.errors.each { warnings.record("[#{s.name}] #{it}") } }
|
|
186
|
+
summaries.compact
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @return [Warnings] what this command has held back from the screen
|
|
190
|
+
def warnings = @warnings ||= Warnings.new
|
|
191
|
+
|
|
192
|
+
def bars(runs, summaries, concurrency)
|
|
193
|
+
ProgressBars.for(ui).run("Running #{runs.size} experiment#{"s" unless runs.size == 1}",
|
|
194
|
+
concurrent: concurrency.zero? || concurrency) do |multi|
|
|
195
|
+
runs.each_with_index do |run, index|
|
|
196
|
+
multi.progress(run.name, total: run.cases.size) do |bar|
|
|
197
|
+
summaries[index] = run.run(on_case: -> { bar.advance })
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# @param names [Array<String>] as given to -d, possibly comma-separated; empty means every dataset
|
|
204
|
+
# @return [Array<String>]
|
|
205
|
+
# @raise [Error] on an unknown dataset, before anything is spent
|
|
206
|
+
def datasets(names)
|
|
207
|
+
names = names.flat_map { it.split(",") }.uniq
|
|
208
|
+
return discovery.datasets.tap { raise Error, "no datasets under #{discovery.root}" if it.empty? } if names.empty?
|
|
209
|
+
|
|
210
|
+
names.each { discovery.cases(dataset: it) }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Prints what the block writes, then the leaderboard, its glossary and
|
|
214
|
+
# the scores; then says where the warnings went, if any, and opens the
|
|
215
|
+
# best-scoring experiment.
|
|
216
|
+
#
|
|
217
|
+
# With -o, the block's output goes to the file alone, and the rest to
|
|
218
|
+
# both, each drawn for its own stream. The file is in color and ends
|
|
219
|
+
# with the time it was closed, as dry-cli-ui's own reports do.
|
|
220
|
+
#
|
|
221
|
+
# @param summaries [Array<Experiment::Summary>]
|
|
222
|
+
# @param output [String, nil] what -o was given: nil, "-", "" for the default file, or a path
|
|
223
|
+
# @param open [Boolean]
|
|
224
|
+
# @return [void]
|
|
225
|
+
def report(summaries, output:, open:, &details)
|
|
226
|
+
path = invocation.report_path(output)
|
|
227
|
+
if path
|
|
228
|
+
File.open(path, "w") do |file|
|
|
229
|
+
report = Dry::CLI::UI::Report.new(file)
|
|
230
|
+
writing_to(report) do
|
|
231
|
+
details&.call
|
|
232
|
+
leaderboard(summaries)
|
|
233
|
+
end
|
|
234
|
+
report.puts "Closed at #{Time.now.strftime("%Y-%m-%d %H:%M:%S %z")}"
|
|
235
|
+
end
|
|
236
|
+
else
|
|
237
|
+
details&.call
|
|
238
|
+
end
|
|
239
|
+
leaderboard(summaries)
|
|
240
|
+
status_box(:info, "Report written to #{relative(path)}", stream: err) if path
|
|
241
|
+
save_warnings
|
|
242
|
+
url = summaries.max_by { it.score || -1 }&.permalink
|
|
243
|
+
status_box(:warning, "could not open #{url}", stream: err) if open && url && !configuration.runtime.browse(url)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Saves {#warnings}, if there are any, to
|
|
247
|
+
# log/<command>.warnings.<YYYY-MM-DD>.<HHMMSS>.log at the repository
|
|
248
|
+
# root, stamped with the time the process started, and says how many.
|
|
249
|
+
#
|
|
250
|
+
# @return [void]
|
|
251
|
+
def save_warnings
|
|
252
|
+
return if warnings.none?
|
|
253
|
+
|
|
254
|
+
stamp = Dry::CLI::UI.started_at.strftime("%Y-%m-%d.%H%M%S")
|
|
255
|
+
path = File.join(Dry::CLI::UI::Invocation.root(Dir.pwd), "log", "#{invocation.action}.warnings.#{stamp}.log")
|
|
256
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
257
|
+
warnings.save(path)
|
|
258
|
+
count = warnings.count
|
|
259
|
+
status_box(:warning, "#{count} #{count == 1 ? "Warning was" : "Warnings were"} recorded in #{relative(path)}", stream: err)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# @return [Dry::CLI::UI::Invocation] how the command was run, which names its files
|
|
263
|
+
def invocation = ui.send(:invocation)
|
|
264
|
+
|
|
265
|
+
def relative(path) = Pathname(path).relative_path_from(Dir.pwd).to_s
|
|
266
|
+
|
|
267
|
+
# Points the tables and boxes at another stream while the block runs.
|
|
268
|
+
# They keep the screen's width.
|
|
269
|
+
#
|
|
270
|
+
# @param io [IO, nil] nil to leave them on the screen
|
|
271
|
+
def writing_to(io)
|
|
272
|
+
return yield if io.nil?
|
|
273
|
+
|
|
274
|
+
screen = out
|
|
275
|
+
@screen = screen
|
|
276
|
+
@out = io
|
|
277
|
+
@pastel = nil
|
|
278
|
+
yield
|
|
279
|
+
ensure
|
|
280
|
+
if io
|
|
281
|
+
@out = screen
|
|
282
|
+
@screen = nil
|
|
283
|
+
@pastel = nil
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def percent(value) = value.nil? ? "-" : format("%.1f%%", value * 100)
|
|
288
|
+
|
|
289
|
+
def dollars(value) = format("$%.4f", value.to_f)
|
|
290
|
+
|
|
291
|
+
def primary = scorer.primary
|
|
292
|
+
|
|
293
|
+
# The best value: the cheapest entry within {VALUE_MARGIN} of the best
|
|
294
|
+
# primary metric. Entries with errors, or no score, do not compete. A
|
|
295
|
+
# plain score-per-dollar ratio would crown whatever is nearly free,
|
|
296
|
+
# however wrong it is.
|
|
297
|
+
#
|
|
298
|
+
# @param entries [Array<Array(Float, Float, Integer)>] primary metric, cost, errors
|
|
299
|
+
# @return [Integer, nil] the winner's index
|
|
300
|
+
def best_value(entries)
|
|
301
|
+
eligible = entries.each_index.select { entries[it][0] && entries[it][2].zero? }
|
|
302
|
+
return nil if eligible.empty?
|
|
303
|
+
|
|
304
|
+
floor = eligible.map { entries[it][0] }.max - VALUE_MARGIN
|
|
305
|
+
eligible.select { entries[it][0] >= floor - 1e-9 }.min_by { entries[it][1] }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def value_line(label, value, cost)
|
|
309
|
+
out.puts "best value#{label}: #{format("%.1f%%", value * 100)} at #{dollars(cost)}, " \
|
|
310
|
+
"the cheapest within #{(VALUE_MARGIN * 100).round} points of the best #{primary.name}"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def contender_label(summary) = "#{summary.model.id} effort=#{summary.model.effort || "default"}"
|
|
314
|
+
|
|
315
|
+
def temperature_cell(summary) = summary.model.temperature.nil? ? "default" : summary.model.temperature
|
|
316
|
+
|
|
317
|
+
# Tokens, cost and the mean primary metric per contender, over every
|
|
318
|
+
# dataset it ran.
|
|
319
|
+
def totals(summaries)
|
|
320
|
+
groups = summaries.group_by { [it.model.key, it.model.effort, it.model.temperature] }.values.map do |group|
|
|
321
|
+
values = group.filter_map { it.mean(primary) }
|
|
322
|
+
{ first: group.first, errors: group.sum { it.errors.size },
|
|
323
|
+
value: values.size == group.size ? values.sum / values.size : nil,
|
|
324
|
+
usage: group.map(&:usage).reduce(Usage.zero, :+) }
|
|
325
|
+
end
|
|
326
|
+
groups.sort_by! { -(it[:value] || -1) }
|
|
327
|
+
rows = groups.map do |g|
|
|
328
|
+
s = g[:first]
|
|
329
|
+
[s.model.id, s.model.effort || "default", temperature_cell(s), percent(g[:value]),
|
|
330
|
+
g[:errors], g[:usage].input_tokens, g[:usage].output_tokens, dollars(g[:usage].cost_usd)]
|
|
331
|
+
end
|
|
332
|
+
best = best_value(groups.map { [it[:value], it[:usage].cost_usd, it[:errors]] })
|
|
333
|
+
table(rows,
|
|
334
|
+
header: ["model", "effort", "temp", primary.name, "errors", "tok_in", "tok_out", "cost"],
|
|
335
|
+
highlight: [best].compact,
|
|
336
|
+
fill: 0)
|
|
337
|
+
return unless best
|
|
338
|
+
|
|
339
|
+
g = groups[best]
|
|
340
|
+
value_line(" overall (#{contender_label(g[:first])})", g[:value], g[:usage].cost_usd)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Every experiment by its mean score, best first, with its cost and its
|
|
344
|
+
# Braintrust page. URLs are never wrapped, so they stay clickable.
|
|
345
|
+
def scores(summaries)
|
|
346
|
+
rows = summaries.sort_by { -(it.score || -1) }.map do |s|
|
|
347
|
+
[s.model.id, s.model.effort || "default", s.dataset, percent(s.score), dollars(s.usage.cost_usd), s.permalink || "-"]
|
|
348
|
+
end
|
|
349
|
+
table(rows, header: %w[model effort dataset score cost url], fill: 0)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# The best value in each dataset's block of the leaderboard.
|
|
353
|
+
#
|
|
354
|
+
# @param ranked [Array<Experiment::Summary>]
|
|
355
|
+
# @return [Array<Integer>] indices into ranked
|
|
356
|
+
def best_per_dataset(ranked)
|
|
357
|
+
ranked.each_index.group_by { ranked[it].dataset }.values.filter_map do |indices|
|
|
358
|
+
winner = best_value(indices.map { [ranked[it].mean(primary), ranked[it].usage.cost_usd, ranked[it].errors.size] })
|
|
359
|
+
winner && indices[winner]
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# One row per experiment, best primary metric first, with the best
|
|
364
|
+
# value per dataset; the glossary; the totals, over several datasets;
|
|
365
|
+
# and every experiment by score.
|
|
366
|
+
def leaderboard(summaries)
|
|
367
|
+
ranked = summaries.sort_by { [it.dataset, -(it.mean(primary) || -1)] }
|
|
368
|
+
rows = ranked.map do |s|
|
|
369
|
+
[s.dataset, s.model.id, s.model.effort || "default", temperature_cell(s),
|
|
370
|
+
*metrics.map { percent(s.mean(it)) },
|
|
371
|
+
s.errors.size, format("%.1fs", s.duration), s.usage.input_tokens, s.usage.output_tokens, dollars(s.usage.cost_usd)]
|
|
372
|
+
end
|
|
373
|
+
best = best_per_dataset(ranked)
|
|
374
|
+
table(rows,
|
|
375
|
+
header: ["dataset", "model", "effort", "temp", *metrics.map(&:name), "errors", "time", "tok_in", "tok_out", "cost"],
|
|
376
|
+
highlight: best)
|
|
377
|
+
best.each do |index|
|
|
378
|
+
s = ranked[index]
|
|
379
|
+
value_line(" [#{s.dataset}] (#{contender_label(s)})", s.mean(primary), s.usage.cost_usd)
|
|
380
|
+
end
|
|
381
|
+
table(glossary, header: %w[Term Description], fill: 1, wrap: true)
|
|
382
|
+
totals(summaries) if summaries.map(&:dataset).uniq.size > 1
|
|
383
|
+
scores(summaries)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# @return [Array<Array(String, String)>] the metrics' descriptions, then the gem's columns
|
|
387
|
+
def glossary
|
|
388
|
+
metrics.map { [it.name, it.description.to_s.empty? ? "(no description)" : it.description] } + GLOSSARY.to_a
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
module CLI
|
|
6
|
+
# dry-cli-ui's multi_progress, drawn differently:
|
|
7
|
+
#
|
|
8
|
+
# - a flat list, the jobs indented under the headline, with no tree;
|
|
9
|
+
# - the headline's bar, which counts every case, in yellow;
|
|
10
|
+
# - a finished job keeps its bar, full, with the time it took where the
|
|
11
|
+
# ETA was, instead of turning into a one-line summary;
|
|
12
|
+
# - labels padded to at least {LABEL_WIDTH} columns;
|
|
13
|
+
# - each frame overwrites the last line by line, inside a synchronized
|
|
14
|
+
# update, rather than clearing the screen below and drawing it again,
|
|
15
|
+
# which flickers with many rows.
|
|
16
|
+
class ProgressBars < Dry::CLI::UI::Widgets::MultiProgress
|
|
17
|
+
# The fewest columns a label is padded to.
|
|
18
|
+
LABEL_WIDTH = 40
|
|
19
|
+
|
|
20
|
+
# What each job's row starts with.
|
|
21
|
+
INDENT = " "
|
|
22
|
+
|
|
23
|
+
# The headline's bar color.
|
|
24
|
+
HEADLINE_COLOR = :yellow
|
|
25
|
+
|
|
26
|
+
# Rows that have ended and drew a bar while they ran.
|
|
27
|
+
ENDED = %i[done failed].freeze
|
|
28
|
+
|
|
29
|
+
# Tells the terminal to show a frame only once all of it has arrived.
|
|
30
|
+
# Terminals that do not know these ignore them.
|
|
31
|
+
SYNC_START = "\e[?2026h"
|
|
32
|
+
SYNC_END = "\e[?2026l"
|
|
33
|
+
|
|
34
|
+
# @param console [Dry::CLI::UI::Console] the command's ui, whose stream, clock and settings it draws with
|
|
35
|
+
def self.for(console) = new(console.send(:err), clock: console.send(:clock), config: console.send(:config))
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def label_width = [LABEL_WIDTH, title.length, *jobs.map { it.label.length }].max + INDENT.length
|
|
40
|
+
|
|
41
|
+
def rows
|
|
42
|
+
width = label_width
|
|
43
|
+
[headline(width), *visible.map { "#{INDENT}#{row(it, width - INDENT.length)}" }]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def redraw
|
|
47
|
+
drawn = rows
|
|
48
|
+
cursor = terminal.cursor
|
|
49
|
+
up = @drawn.positive? ? cursor.up(@drawn) : ""
|
|
50
|
+
lines = drawn.map { "\r#{it}#{cursor.clear_line_after}\n" }.join
|
|
51
|
+
below = drawn.size < @drawn ? cursor.clear_screen_down : ""
|
|
52
|
+
terminal.print("#{SYNC_START}#{up}#{lines}#{below}#{SYNC_END}")
|
|
53
|
+
@drawn = drawn.size
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def headline(width)
|
|
57
|
+
return super unless live?
|
|
58
|
+
|
|
59
|
+
finished = state != :running
|
|
60
|
+
bar = full_meter(current, total, started, HEADLINE_COLOR, counts, (@seconds if finished))
|
|
61
|
+
"#{glyph(state)} #{title.ljust(width)} #{bar}#{finished ? noted(headline_note) : stopping}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def row(job, width)
|
|
65
|
+
return super unless ENDED.include?(job.state) && !spinner?(job) && live?
|
|
66
|
+
|
|
67
|
+
handle = job.handle
|
|
68
|
+
bar = full_meter(handle.current, handle.total, job.started, handle.color, handle.counts, job.seconds.to_f)
|
|
69
|
+
"#{glyph(job.state)} #{job.label.ljust(width)} #{bar}#{noted(note(job))}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# {#meter}, with the time taken in place of the ETA once seconds are known.
|
|
73
|
+
def full_meter(done, all, since, color, tally, seconds)
|
|
74
|
+
return meter(done, all, since, color, tally) if seconds.nil?
|
|
75
|
+
|
|
76
|
+
took = Dry::CLI::UI::Duration.format(seconds)
|
|
77
|
+
Dry::CLI::UI::Widgets::Progress.meter(terminal.pastel,
|
|
78
|
+
config,
|
|
79
|
+
done: done,
|
|
80
|
+
all: all,
|
|
81
|
+
columns: bar_columns,
|
|
82
|
+
eta: took,
|
|
83
|
+
color: color,
|
|
84
|
+
counts: tally,
|
|
85
|
+
count_width: count_width).sub("ETA #{took}", "in #{took}")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
module CLI
|
|
6
|
+
# One experiment per model and dataset, each with a per-case breakdown.
|
|
7
|
+
# Without -m, every model .agent-eval.config.yml lists. {Compare} runs the same
|
|
8
|
+
# experiments and prints only the leaderboard.
|
|
9
|
+
class Run < ModelCommand
|
|
10
|
+
desc "Run every case against a model, or every model in .agent-eval.config.yml, with a per-case breakdown"
|
|
11
|
+
|
|
12
|
+
example [
|
|
13
|
+
"-m anthropic/claude-haiku-5.5",
|
|
14
|
+
"-m openai/gpt-4.1-mini -t 0 -c clear/00 # one variant's first cases",
|
|
15
|
+
"-m google/gemini-2.5-flash -e high --local # score without Braintrust",
|
|
16
|
+
"-d colors,animals -o # both datasets; the breakdowns go to log/",
|
|
17
|
+
"-n # show what would run, call nothing"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
def call(models:, dataset:, dry_run:, **options)
|
|
21
|
+
config = lineup(options)
|
|
22
|
+
contenders = contenders(config, models, options)
|
|
23
|
+
datasets = datasets(dataset)
|
|
24
|
+
return matrix(datasets, contenders) if dry_run
|
|
25
|
+
|
|
26
|
+
setup!(contenders, options)
|
|
27
|
+
runs = datasets.flat_map { |name| contenders.map { experiment(it, config, options, dataset: name) } }
|
|
28
|
+
summaries = execute(runs)
|
|
29
|
+
report(summaries, output: options[:output], open: options[:open]) do
|
|
30
|
+
summaries.each { breakdown(it, titled: summaries.size > 1) }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# One row per case: its score, and whatever the scorer diagnosed.
|
|
37
|
+
def breakdown(summary, titled: false)
|
|
38
|
+
status_box :info, summary.name if titled
|
|
39
|
+
rows = summary.results.map do |result|
|
|
40
|
+
[result.case.id, result.case.difficulty, result.score ? percent(result.score.value) : "-", diagnosis(result)]
|
|
41
|
+
end
|
|
42
|
+
table(rows, header: %w[case difficulty score diagnostics], fill: 3, wrap: true)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The scorer's diagnostics, one per line, rendered without knowing
|
|
46
|
+
# their shape: "field: value" for a Hash, each item for an Array.
|
|
47
|
+
def diagnosis(result)
|
|
48
|
+
return result.error if result.failed?
|
|
49
|
+
|
|
50
|
+
result.score.diagnostics.map do |field, detail|
|
|
51
|
+
case detail
|
|
52
|
+
when Hash then "#{field}: #{detail.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}"
|
|
53
|
+
when Array then "#{field}: #{detail.join(", ")}"
|
|
54
|
+
else "#{field}: #{detail}"
|
|
55
|
+
end
|
|
56
|
+
end.join("\n")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
module CLI
|
|
6
|
+
# Offline checks that every case agrees with what its suite demands of it.
|
|
7
|
+
class Verify < Command
|
|
8
|
+
desc "Check every case's input and expected answer, through the adapter and the scorer (no API calls)"
|
|
9
|
+
|
|
10
|
+
def call(**)
|
|
11
|
+
all = discovery.all
|
|
12
|
+
failures = Agent::Evals::Verify.new(configuration).problems
|
|
13
|
+
if failures.empty?
|
|
14
|
+
status_box :success, "#{all.size} cases verified across #{discovery.datasets.join(", ")}"
|
|
15
|
+
return
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
failures.each { |name, problems| problems.each { status_box :error, "#{name}: #{it}", stream: err } }
|
|
19
|
+
raise Error, "#{failures.size} case(s) failed verification"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
# Every command the executable answers to. The help's title and
|
|
6
|
+
# description come from the suite's configuration, which the executable
|
|
7
|
+
# loads before this module is first referenced.
|
|
8
|
+
module CLI
|
|
9
|
+
extend Dry::CLI::Registry
|
|
10
|
+
|
|
11
|
+
PROGRAM = "agent-evals"
|
|
12
|
+
|
|
13
|
+
Dry::CLI::Help.configure do
|
|
14
|
+
configuration = Agent::Evals.configuration
|
|
15
|
+
title configuration.name == PROGRAM ? PROGRAM : "#{PROGRAM} (#{configuration.name})"
|
|
16
|
+
types = configuration.providers.uniq(&:request_type).map { "#{it.request_type} (#{it.request_type_description})" }
|
|
17
|
+
description "#{configuration.description} Request types: #{types.join("; ")}."
|
|
18
|
+
|
|
19
|
+
columns = IO.console&.winsize&.last || 80
|
|
20
|
+
width columns.clamp(60, 120)
|
|
21
|
+
exit_code_without_arguments 0
|
|
22
|
+
|
|
23
|
+
styles do
|
|
24
|
+
heading :bold, :cyan, case: :UPPERCASE
|
|
25
|
+
usage :yellow
|
|
26
|
+
example :yellow
|
|
27
|
+
option :green
|
|
28
|
+
example_comment :bright_black
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
register "run", Run
|
|
33
|
+
register "compare", Compare
|
|
34
|
+
register "cases", Cases
|
|
35
|
+
register "verify", Verify
|
|
36
|
+
register "version", Version, aliases: ["-V", "--version"]
|
|
37
|
+
register "completion", Dry::CLI::Autocomplete::Command[self, program_name: PROGRAM]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
# What a {Model} returns from one call: the text, what it cost, and the
|
|
6
|
+
# provider's raw response for an adapter that needs more than the text.
|
|
7
|
+
Completion = Data.define(:text, :usage, :raw) do
|
|
8
|
+
def initialize(text:, usage: Usage.zero, raw: nil) = super(text: text.to_s, usage: Usage.from(usage), raw: raw)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|