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,437 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module LLM
|
|
8
|
+
module Evals
|
|
9
|
+
module CLI
|
|
10
|
+
# The options shared by the commands that call a model. Settings resolve
|
|
11
|
+
# flag first, then the config entry, then .llm-evals.config.yml's defaults.
|
|
12
|
+
#
|
|
13
|
+
# Every column that is not a metric is the gem's; the metric columns,
|
|
14
|
+
# their glossary and the ranking come from the suite's {Scorer#metrics}.
|
|
15
|
+
class ModelCommand < Command
|
|
16
|
+
include Formatting
|
|
17
|
+
|
|
18
|
+
def self.inherited(subclass)
|
|
19
|
+
super
|
|
20
|
+
subclass.extend(Dry::CLI::UI::Flags)
|
|
21
|
+
subclass.flags(:dry_run, :output)
|
|
22
|
+
subclass.option :models,
|
|
23
|
+
type: :array,
|
|
24
|
+
aliases: ["-m"],
|
|
25
|
+
default: [],
|
|
26
|
+
desc: "Model ids, comma-separated, instead of the config file's vendors"
|
|
27
|
+
subclass.option :dataset,
|
|
28
|
+
type: :array,
|
|
29
|
+
aliases: ["-d"],
|
|
30
|
+
default: [],
|
|
31
|
+
desc: "Datasets, comma-separated; every dataset when omitted. Each model runs on each"
|
|
32
|
+
subclass.option :config, aliases: ["-C"], desc: "The lineup file (default: the suite's .llm-evals.config.yml)"
|
|
33
|
+
subclass.option :effort, aliases: ["-e"], desc: "Reasoning effort: default, or one the provider accepts (overrides the config file)"
|
|
34
|
+
subclass.option :temperature, aliases: ["-t"], desc: "Sampling temperature, or 'default' to send none (overrides the config file)"
|
|
35
|
+
subclass.option :max_tokens, desc: "Output token cap, reasoning included (overrides the config file)"
|
|
36
|
+
subclass.option :case,
|
|
37
|
+
type: :array,
|
|
38
|
+
aliases: ["-c"],
|
|
39
|
+
default: [],
|
|
40
|
+
desc: "Only these cases, by id prefix: -c clear or -c clear/003,vague/001"
|
|
41
|
+
subclass.option :parallelism, aliases: ["-p"], desc: "Cases in flight at once, per model (default: the config's defaults.parallelism)"
|
|
42
|
+
subclass.option :concurrency,
|
|
43
|
+
aliases: ["-j"],
|
|
44
|
+
desc: "Experiments running at once; 0 runs them all together (default: the number of CPUs)"
|
|
45
|
+
subclass.option :project,
|
|
46
|
+
desc: "Braintrust project name; default is the BRAINTRUST_PROJECT_ID project, else the config file's project"
|
|
47
|
+
subclass.option :open,
|
|
48
|
+
type: :boolean,
|
|
49
|
+
default: true,
|
|
50
|
+
desc: "Open the best experiment in the browser when done (no effect with --local)"
|
|
51
|
+
subclass.option :scores,
|
|
52
|
+
type: :boolean,
|
|
53
|
+
default: false,
|
|
54
|
+
desc: "Also print every experiment by score, with its cost and its tracker's link"
|
|
55
|
+
subclass.option :totals,
|
|
56
|
+
values: %w[overall variant both],
|
|
57
|
+
default: "overall",
|
|
58
|
+
desc: "Group TUI totals by overall results, case variant or both"
|
|
59
|
+
subclass.option :format,
|
|
60
|
+
values: %w[tui html json],
|
|
61
|
+
desc: "Override the configured display: tui, html or json"
|
|
62
|
+
subclass.option :display,
|
|
63
|
+
values: %w[none tui browser],
|
|
64
|
+
desc: "Override results.display from the lineup"
|
|
65
|
+
subclass.option :autosave,
|
|
66
|
+
type: :boolean,
|
|
67
|
+
default: nil,
|
|
68
|
+
desc: "Save a JSON snapshot regardless of results.autosave"
|
|
69
|
+
subclass.option :destination,
|
|
70
|
+
desc: "Override results.destination"
|
|
71
|
+
subclass.option :temp_dir,
|
|
72
|
+
desc: "Override results.temp_dir"
|
|
73
|
+
subclass.option :local,
|
|
74
|
+
type: :boolean,
|
|
75
|
+
default: false,
|
|
76
|
+
desc: "No remote tracker: results go to log/ only (still calls the models)"
|
|
77
|
+
subclass.option :tracker,
|
|
78
|
+
desc: "The remote tracker: braintrust, logfire, none, or one the suite registers " \
|
|
79
|
+
"(default: the config's tracker:, else the one whose key is in the environment)"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# @return [Config] the lineup: --config, else the suite's
|
|
85
|
+
def lineup(options) = options[:config] ? Config.new(File.expand_path(options[:config])) : configuration.lineup
|
|
86
|
+
|
|
87
|
+
# @return [Providers::Registry]
|
|
88
|
+
def providers = configuration.providers
|
|
89
|
+
|
|
90
|
+
# @return [Scorer]
|
|
91
|
+
def scorer = configuration.scorer
|
|
92
|
+
|
|
93
|
+
# @return [Array<Metric>]
|
|
94
|
+
def metrics = @report_metrics || scorer.metrics
|
|
95
|
+
|
|
96
|
+
# Reads the file's weights before anything is spent, so a bad one fails
|
|
97
|
+
# the run rather than the report at its end.
|
|
98
|
+
#
|
|
99
|
+
# @return [WeightedScore, nil] nil when the file sets none
|
|
100
|
+
# @raise [Error] on a bad weight
|
|
101
|
+
def weigh(config)
|
|
102
|
+
config.results # validate output settings before model calls start
|
|
103
|
+
weights = config.score_weights
|
|
104
|
+
@weighted = weights.empty? ? nil : WeightedScore.new(weights, metrics: metrics.map(&:name))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @return [Hash{String => Object}] the setting flags that were given
|
|
108
|
+
def overrides(options)
|
|
109
|
+
{ "effort" => options[:effort], "temperature" => options[:temperature], "max_tokens" => options[:max_tokens] }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def experiment(contender, config, options, dataset:)
|
|
113
|
+
Experiment.new(
|
|
114
|
+
model: providers.model_for(contender),
|
|
115
|
+
cases: cases_for(options[:case], dataset: dataset),
|
|
116
|
+
adapter: configuration.adapter,
|
|
117
|
+
scorer: scorer,
|
|
118
|
+
parallelism: Integer(options[:parallelism] || config.defaults["parallelism"]),
|
|
119
|
+
trackers: trackers_for(config, options)
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Captures the parsed flags and the settings that actually reached the
|
|
124
|
+
# experiments. The former preserves the invocation; the latter records
|
|
125
|
+
# defaults and expanded case/model selections that were implicit.
|
|
126
|
+
def serialize_run_options(config:, contenders:, datasets:, runs:, models:, dataset:, options:)
|
|
127
|
+
{
|
|
128
|
+
command: invocation.action.to_s,
|
|
129
|
+
cli: { models:, dataset:, **options },
|
|
130
|
+
effective: {
|
|
131
|
+
project: options[:project] || config.project || configuration.project || configuration.name,
|
|
132
|
+
request_types: config.request_types.map(&:to_s),
|
|
133
|
+
datasets:,
|
|
134
|
+
cases: runs.group_by(&:dataset).transform_values { |dataset_runs| dataset_runs.flat_map { it.cases.map(&:id) }.uniq },
|
|
135
|
+
contenders: contenders.map do |contender|
|
|
136
|
+
settings = contender.settings
|
|
137
|
+
{ model: contender.model, provider: providers.for(contender).name, effort: settings[:effort],
|
|
138
|
+
temperature: settings[:temperature], max_tokens: settings[:max_tokens] }
|
|
139
|
+
end,
|
|
140
|
+
defaults: config.defaults,
|
|
141
|
+
parallelism: Integer(options[:parallelism] || config.defaults["parallelism"]),
|
|
142
|
+
concurrency: concurrency(options),
|
|
143
|
+
tracker: options[:local] ? "none" : remote_tracker(config, options)&.tracker_name
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The local JSON log, always, then the one remote tracker if any,
|
|
149
|
+
# each experiment getting instances of its own.
|
|
150
|
+
#
|
|
151
|
+
# @return [Array<Tracker>]
|
|
152
|
+
def trackers_for(config, options)
|
|
153
|
+
context = { suite: configuration.name,
|
|
154
|
+
project: options[:project] || config.project || configuration.project || configuration.name,
|
|
155
|
+
project_id: options[:project] ? nil : ENV["BRAINTRUST_PROJECT_ID"].to_s[/\S+/] }
|
|
156
|
+
local = Trackers::Local.new(dir: File.join(Dry::CLI::UI::Invocation.root(Dir.pwd), "log", "llm-evals"), **context)
|
|
157
|
+
klass = remote_tracker(config, options)
|
|
158
|
+
remote = klass&.new(**context, options: config.tracker_options(klass.tracker_name))
|
|
159
|
+
[local, remote].compact
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The remote tracker class this run uses, chosen once: --tracker,
|
|
163
|
+
# then tracker: in the config, then the environment. --local is none.
|
|
164
|
+
#
|
|
165
|
+
# @return [Class<Tracker>, nil]
|
|
166
|
+
def remote_tracker(config, options)
|
|
167
|
+
return @remote_tracker if defined?(@remote_tracker)
|
|
168
|
+
|
|
169
|
+
chosen = configuration.trackers.choose(flag: options[:tracker], config: config.tracker, env: ENV, local: options[:local])
|
|
170
|
+
@remote_tracker = chosen&.first
|
|
171
|
+
err.puts "tracker: #{chosen ? "#{chosen.first.tracker_name}, because #{chosen.last}" : "none"}" if options[:verbose]
|
|
172
|
+
@remote_tracker
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The contenders a command runs: the models named on the command line,
|
|
176
|
+
# or every model .llm-evals.config.yml lists.
|
|
177
|
+
#
|
|
178
|
+
# @param models [Array<String>] as given to -m, possibly comma-separated
|
|
179
|
+
# @return [Array<Contender>]
|
|
180
|
+
# @raise [Error] when neither names any model
|
|
181
|
+
def contenders(config, models, options)
|
|
182
|
+
models = models.flat_map { it.split(",") }
|
|
183
|
+
found = models.empty? ? config.contenders(overrides(options)) : config.contenders_for(models, overrides(options))
|
|
184
|
+
raise Error, "no models: list some under vendors in #{config.path}, or pass -m" if found.empty?
|
|
185
|
+
|
|
186
|
+
admit(found, config)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Keeps the contenders whose provider produces what the suite declares
|
|
190
|
+
# in `request_types`, and records each refusal in {#warnings} by name,
|
|
191
|
+
# so a refused model is reported rather than run, failed and billed.
|
|
192
|
+
#
|
|
193
|
+
# @return [Array<Contender>] the admitted contenders
|
|
194
|
+
# @raise [Error] when none is admitted, naming every refusal
|
|
195
|
+
def admit(found, config)
|
|
196
|
+
found.each { providers.for(it) } # an unserved id fails here, before anything is spent
|
|
197
|
+
admitted, refusals = providers.admit(found, request_types: config.request_types)
|
|
198
|
+
refusals.each { warnings.record(it.to_s) }
|
|
199
|
+
raise Error, "no admitted models: #{refusals.join("; ")}" if admitted.empty?
|
|
200
|
+
|
|
201
|
+
admitted
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Env, the tracker's gems and key, hooks, provider clients, then the catalogs.
|
|
205
|
+
def setup!(contenders, options)
|
|
206
|
+
configuration.runtime.setup!(providers: providers.used_by(contenders), tracker: remote_tracker(lineup(options), options))
|
|
207
|
+
providers.verify!(contenders)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# What -n prints: every experiment that would run, and where the
|
|
211
|
+
# refusals went, if there were any.
|
|
212
|
+
def matrix(datasets, contenders)
|
|
213
|
+
rows = datasets.product(contenders).map { |name, c| [name, c.model, c.effort, c.temperature, c.max_tokens] }
|
|
214
|
+
table(rows, header: %w[dataset model effort temp max_tokens])
|
|
215
|
+
save_warnings
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Runs the experiments side by side, a progress bar each. What would
|
|
219
|
+
# print as a warning meanwhile, failed cases included, goes to
|
|
220
|
+
# {#warnings} instead.
|
|
221
|
+
#
|
|
222
|
+
# @param runs [Array<Experiment>]
|
|
223
|
+
# @param concurrency [Integer] experiments at once; 0 runs them all together
|
|
224
|
+
# @return [Array<Experiment::Summary>]
|
|
225
|
+
def execute(runs, concurrency:)
|
|
226
|
+
summaries = Array.new(runs.size)
|
|
227
|
+
warnings.capture { bars(runs, summaries, concurrency) }
|
|
228
|
+
summaries.compact.each { |s| s.errors.each { warnings.record("[#{s.name}] #{it}") } }
|
|
229
|
+
summaries.compact
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# -j, else one experiment per CPU. Uncapped, a full lineup started
|
|
233
|
+
# every model x dataset pair at once: 140 experiments, each with its
|
|
234
|
+
# own pool of case threads, all fighting for the network and the screen.
|
|
235
|
+
#
|
|
236
|
+
# @return [Integer] experiments at once; 0 means all of them
|
|
237
|
+
# @raise [Error] on a negative or non-numeric -j
|
|
238
|
+
def concurrency(options)
|
|
239
|
+
value = options[:concurrency] || Etc.nprocessors
|
|
240
|
+
Integer(value).tap { raise Error, "--concurrency must be 0 or more, got #{value}" if it.negative? }
|
|
241
|
+
rescue ArgumentError, TypeError
|
|
242
|
+
raise Error, "--concurrency must be a whole number, got #{value.inspect}"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# @return [Warnings] what this command has held back from the screen
|
|
246
|
+
def warnings = @warnings ||= Warnings.new
|
|
247
|
+
|
|
248
|
+
def bars(runs, summaries, concurrency)
|
|
249
|
+
ProgressBars.for(ui, colors: model_colors).run("Running #{runs.size} experiment#{"s" unless runs.size == 1}",
|
|
250
|
+
concurrent: concurrency.zero? || concurrency) do |multi|
|
|
251
|
+
labels = ProgressBars.labels(runs)
|
|
252
|
+
runs.each_with_index do |run, index|
|
|
253
|
+
multi.progress(labels[index], total: run.cases.size) do |bar|
|
|
254
|
+
summaries[index] = run.run(on_case: -> { bar.advance })
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# @param names [Array<String>] as given to -d, possibly comma-separated; empty means every dataset
|
|
261
|
+
# @return [Array<String>]
|
|
262
|
+
# @raise [Error] on an unknown dataset, before anything is spent
|
|
263
|
+
def datasets(names)
|
|
264
|
+
names = names.flat_map { it.split(",") }.uniq
|
|
265
|
+
return discovery.datasets.tap { raise Error, "no datasets under #{discovery.root}" if it.empty? } if names.empty?
|
|
266
|
+
|
|
267
|
+
names.each { discovery.cases(dataset: it) }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Prints what the block writes, then the leaderboard, its glossary and
|
|
271
|
+
# the scores; then says where the warnings went, if any, and opens the
|
|
272
|
+
# best-scoring experiment.
|
|
273
|
+
#
|
|
274
|
+
# Saves or renders through {Render::Reports::Pipeline}; the configured
|
|
275
|
+
# display and autosave policy are independent of model execution.
|
|
276
|
+
#
|
|
277
|
+
# @param summaries [Array<Experiment::Summary>]
|
|
278
|
+
# @param output [String, nil] what -o was given: nil, "-", "" for the default file, or a path
|
|
279
|
+
# @param open [Boolean]
|
|
280
|
+
# @param scores [Boolean] also print every experiment by score
|
|
281
|
+
# @param format [String] `tui`, `html` or `json`
|
|
282
|
+
# @return [void]
|
|
283
|
+
def report(summaries, config:, options:, run_options:, output:, open:, scores: false, format: nil, &details)
|
|
284
|
+
results = effective_results(config, options)
|
|
285
|
+
results[:display] = { "tui" => "tui", "none" => "none", "html" => "browser", "json" => "none" }.fetch(format, results[:display]) if format
|
|
286
|
+
snapshot_options = run_options.merge(results:)
|
|
287
|
+
pipeline = render_pipeline(results, run_options)
|
|
288
|
+
|
|
289
|
+
if format == "html"
|
|
290
|
+
json_path = if results[:autosave]
|
|
291
|
+
pipeline.save_json(summaries:, metrics:, weighted_score: @weighted, run_options: snapshot_options)
|
|
292
|
+
end
|
|
293
|
+
json = if json_path
|
|
294
|
+
File.read(json_path)
|
|
295
|
+
else
|
|
296
|
+
pipeline.write_json(summaries:, metrics:, weighted_score: @weighted, run_options: snapshot_options, path: nil)
|
|
297
|
+
end
|
|
298
|
+
if json_path
|
|
299
|
+
status_box(:success, "JSON results written to #{relative(json_path)}", stream: err)
|
|
300
|
+
end
|
|
301
|
+
path = report_output_path(output, "html", pipeline)
|
|
302
|
+
document = pipeline.write_html(json:, path:)
|
|
303
|
+
path ? status_box(:success, "HTML report written to #{relative(path)}", stream: err) : out.write(document)
|
|
304
|
+
save_warnings
|
|
305
|
+
if open && path
|
|
306
|
+
url = URI::DEFAULT_PARSER.escape("file://#{path}")
|
|
307
|
+
status_box(:warning, "could not open #{path}", stream: err) unless configuration.runtime.browse(url)
|
|
308
|
+
end
|
|
309
|
+
return
|
|
310
|
+
end
|
|
311
|
+
if format == "json"
|
|
312
|
+
if output.nil? || output.empty?
|
|
313
|
+
path = pipeline.save_json(summaries:, metrics:, weighted_score: @weighted, run_options: snapshot_options)
|
|
314
|
+
status_box(:success, "JSON report written to #{relative(path)}", stream: err)
|
|
315
|
+
else
|
|
316
|
+
path = report_output_path(output, "json", pipeline)
|
|
317
|
+
document = pipeline.write_json(summaries:, metrics:, weighted_score: @weighted, run_options: snapshot_options, path:)
|
|
318
|
+
path ? status_box(:success, "JSON report written to #{relative(path)}", stream: err) : out.write(document)
|
|
319
|
+
end
|
|
320
|
+
save_warnings
|
|
321
|
+
return
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
result = pipeline.call(summaries:,
|
|
325
|
+
metrics:,
|
|
326
|
+
weighted_score: @weighted,
|
|
327
|
+
run_options: snapshot_options,
|
|
328
|
+
autosave: results[:autosave],
|
|
329
|
+
display: results[:display],
|
|
330
|
+
open:) do |report|
|
|
331
|
+
@report_metrics = report.metrics
|
|
332
|
+
@weighted = report.weighted_score
|
|
333
|
+
terminal_report(report.summaries, output:, open:, scores:, totals_mode: options[:totals]) do |shown|
|
|
334
|
+
details&.call(shown)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
if result.json_path
|
|
338
|
+
status_box(:success, "JSON results written to #{relative(result.json_path)}", stream: err)
|
|
339
|
+
end
|
|
340
|
+
if result.html_path
|
|
341
|
+
status_box(:success, "HTML report written to #{relative(result.html_path)}", stream: err)
|
|
342
|
+
status_box(:warning, "could not open #{result.html_path}", stream: err) if open && !result.browser_opened
|
|
343
|
+
end
|
|
344
|
+
save_warnings unless results[:display] == "tui"
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def terminal_report(summaries, output:, open:, scores:, totals_mode:, &details)
|
|
348
|
+
terminal = LLM::Evals::Render::Reports::Terminal.new(invocation:, writing_to: method(:writing_to), leaderboard: method(:leaderboard))
|
|
349
|
+
path = terminal.call(summaries:, output:, scores:, totals_mode:, &details)
|
|
350
|
+
status_box(:info, "Report written to #{relative(path)}", stream: err) if path
|
|
351
|
+
save_warnings
|
|
352
|
+
url = summaries.max_by { it.score || -1 }&.permalink
|
|
353
|
+
status_box(:warning, "could not open #{url}", stream: err) if open && url && !configuration.runtime.browse(url)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def effective_results(config, options)
|
|
357
|
+
configured = config.results
|
|
358
|
+
{
|
|
359
|
+
autosave: options[:autosave].nil? ? configured.autosave : options[:autosave],
|
|
360
|
+
destination: File.expand_path(options[:destination] || configured.destination),
|
|
361
|
+
temp_dir: File.expand_path(options[:temp_dir] || configured.temp_dir),
|
|
362
|
+
display: options[:display] || configured.display_mode
|
|
363
|
+
}
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def render_pipeline(results, run_options)
|
|
367
|
+
project = run_options.dig(:effective, :project) || run_options.dig("effective", "project") || configuration.name
|
|
368
|
+
LLM::Evals::Render::Reports::Pipeline.new(providers:,
|
|
369
|
+
runtime: configuration.runtime,
|
|
370
|
+
destination: results[:destination],
|
|
371
|
+
temp_dir: results[:temp_dir],
|
|
372
|
+
project:)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def report_output_path(output, extension, pipeline)
|
|
376
|
+
return nil if output == "-"
|
|
377
|
+
return pipeline.report_path(File.dirname(invocation.report_path("")), extension) if output.nil? || output.empty?
|
|
378
|
+
|
|
379
|
+
invocation.report_path(output).sub(/\.log\z/, ".#{extension}")
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Saves {#warnings}, if there are any, to
|
|
383
|
+
# log/<command>.warnings.<YYYY-MM-DD>.<HHMMSS>.log at the repository
|
|
384
|
+
# root, stamped with the time the process started, and says how many.
|
|
385
|
+
#
|
|
386
|
+
# @return [void]
|
|
387
|
+
def save_warnings
|
|
388
|
+
return if warnings.none?
|
|
389
|
+
|
|
390
|
+
stamp = Dry::CLI::UI.started_at.strftime("%Y-%m-%d.%H%M%S")
|
|
391
|
+
path = File.join(Dry::CLI::UI::Invocation.root(Dir.pwd), "log", "#{invocation.action}.warnings.#{stamp}.log")
|
|
392
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
393
|
+
warnings.save(path)
|
|
394
|
+
count = warnings.count
|
|
395
|
+
status_box(:warning, "#{count} #{count == 1 ? "Warning was" : "Warnings were"} recorded in #{relative(path)}", stream: err)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# @return [Dry::CLI::UI::Invocation] how the command was run, which names its files
|
|
399
|
+
def invocation = ui.send(:invocation)
|
|
400
|
+
|
|
401
|
+
def relative(path) = Pathname(path).relative_path_from(Dir.pwd).to_s
|
|
402
|
+
|
|
403
|
+
# Points the tables and boxes at another stream while the block runs.
|
|
404
|
+
# They keep the screen's width.
|
|
405
|
+
#
|
|
406
|
+
# @param io [IO, nil] nil to leave them on the screen
|
|
407
|
+
def writing_to(io)
|
|
408
|
+
return yield if io.nil?
|
|
409
|
+
|
|
410
|
+
screen = out
|
|
411
|
+
@screen = screen
|
|
412
|
+
@out = io
|
|
413
|
+
@pastel = nil
|
|
414
|
+
yield
|
|
415
|
+
ensure
|
|
416
|
+
if io
|
|
417
|
+
@out = screen
|
|
418
|
+
@screen = nil
|
|
419
|
+
@pastel = nil
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def primary = metrics.find(&:primary) || scorer.primary
|
|
424
|
+
|
|
425
|
+
def model_colors = @model_colors ||= ModelColors.new
|
|
426
|
+
|
|
427
|
+
# Built per call: {#report} swaps the metrics and the weights for those of
|
|
428
|
+
# the saved snapshot, and {#writing_to} swaps the stream, before this runs.
|
|
429
|
+
def leaderboard(summaries, scores: false, totals_mode: "overall")
|
|
430
|
+
standings = Standings.new(metrics:, primary:, weighted: @weighted)
|
|
431
|
+
Leaderboard.new(standings:, table: method(:table), out:, pastel:, colors: model_colors)
|
|
432
|
+
.call(summaries, scores:, totals_mode:)
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
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
|
+
# - on a screen too short for every job, a finished row stays for
|
|
13
|
+
# {LINGER} seconds, since a short job would otherwise leave the screen
|
|
14
|
+
# before anyone saw it full: at two cases, its last frame is 50%;
|
|
15
|
+
# - labels padded to at least {LABEL_WIDTH} columns, in the columns
|
|
16
|
+
# {.labels} lays out, each model in a bright color of its own for the
|
|
17
|
+
# whole run, chosen at random;
|
|
18
|
+
# - each frame overwrites the last line by line, inside a synchronized
|
|
19
|
+
# update, rather than clearing the screen below and drawing it again,
|
|
20
|
+
# which flickers with many rows.
|
|
21
|
+
class ProgressBars < Dry::CLI::UI::Widgets::MultiProgress
|
|
22
|
+
# The fewest columns a label is padded to.
|
|
23
|
+
LABEL_WIDTH = 40
|
|
24
|
+
|
|
25
|
+
# What each job's row starts with.
|
|
26
|
+
INDENT = " "
|
|
27
|
+
|
|
28
|
+
# The headline's bar color.
|
|
29
|
+
HEADLINE_COLOR = :yellow
|
|
30
|
+
|
|
31
|
+
# Rows that have ended and drew a bar while they ran.
|
|
32
|
+
ENDED = %i[done failed].freeze
|
|
33
|
+
|
|
34
|
+
# Seconds a finished row stays on a screen too short for every job,
|
|
35
|
+
# at most; see {#visible}.
|
|
36
|
+
LINGER = 1.5
|
|
37
|
+
|
|
38
|
+
# Tells the terminal to show a frame only once all of it has arrived.
|
|
39
|
+
# Terminals that do not know these ignore them.
|
|
40
|
+
SYNC_START = "\e[?2026h"
|
|
41
|
+
SYNC_END = "\e[?2026l"
|
|
42
|
+
|
|
43
|
+
# Stands between a label's fixed columns and its model.
|
|
44
|
+
MODEL_SEPARATOR = " | "
|
|
45
|
+
|
|
46
|
+
attr_writer :model_colors
|
|
47
|
+
|
|
48
|
+
# @param console [Dry::CLI::UI::Console] the command's ui, whose stream, clock and settings it draws with
|
|
49
|
+
# @param colors [ModelColors] shared with whatever else draws the models
|
|
50
|
+
def self.for(console, colors: ModelColors.new)
|
|
51
|
+
new(console.send(:err), clock: console.send(:clock), config: console.send(:config)).tap { it.model_colors = colors }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# One label per experiment, the dataset and the settings each padded to
|
|
55
|
+
# the widest of its column, so that every model starts in one column:
|
|
56
|
+
# `[01-bookkeeping-cfo] effort=low t=0.2 | moonshotai/kimi-k3`.
|
|
57
|
+
#
|
|
58
|
+
# @param runs [Array<Experiment>]
|
|
59
|
+
# @return [Array<String>] in the order given
|
|
60
|
+
def self.labels(runs)
|
|
61
|
+
datasets = runs.map { "[#{it.dataset}]" }
|
|
62
|
+
dataset_width = datasets.map(&:length).max
|
|
63
|
+
settings_width = runs.map { it.model.label.length }.max
|
|
64
|
+
runs.zip(datasets).map do |run, dataset|
|
|
65
|
+
"#{dataset.ljust(dataset_width)} #{run.model.label.ljust(settings_width)}#{MODEL_SEPARATOR}#{run.model.id}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# @return [Integer]
|
|
72
|
+
def label_width = [LABEL_WIDTH, title.length, *jobs.map { it.label.length }].max + INDENT.length
|
|
73
|
+
|
|
74
|
+
def rows
|
|
75
|
+
width = label_width
|
|
76
|
+
[headline(width), *visible.map { "#{INDENT}#{painted(row(it, width - INDENT.length))}" }]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param text [String] a job's row, whose label holds the width it was padded to
|
|
80
|
+
# @return [String] the row with its model in that model's color
|
|
81
|
+
def painted(text)
|
|
82
|
+
text.sub(/(?<=#{Regexp.escape(MODEL_SEPARATOR)})\S+/) { terminal.pastel.public_send(model_colors[it], it) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def model_colors = @model_colors ||= ModelColors.new
|
|
86
|
+
|
|
87
|
+
# The running jobs and those just finished, in the order declared. The
|
|
88
|
+
# finished get at most half the rows, newest first: when jobs end
|
|
89
|
+
# faster than rows linger, the oldest must not hold the screen.
|
|
90
|
+
def visible
|
|
91
|
+
return super unless crowded? && state == :running
|
|
92
|
+
|
|
93
|
+
slots = [terminal.height - 2, 0].max
|
|
94
|
+
now = clock.call
|
|
95
|
+
finished = jobs.select { lingering?(it, now) }.max_by(slots / 2) { finished_at(it) }
|
|
96
|
+
jobs & (finished + jobs.select { it.state == :running }.first(slots - finished.size))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def lingering?(job, now) = ENDED.include?(job.state) && now - finished_at(job) < LINGER
|
|
100
|
+
|
|
101
|
+
def finished_at(job) = job.started + job.seconds
|
|
102
|
+
|
|
103
|
+
def redraw
|
|
104
|
+
drawn = rows
|
|
105
|
+
cursor = terminal.cursor
|
|
106
|
+
up = @drawn.positive? ? cursor.up(@drawn) : ""
|
|
107
|
+
lines = drawn.map { "\r#{it}#{cursor.clear_line_after}\n" }.join
|
|
108
|
+
below = drawn.size < @drawn ? cursor.clear_screen_down : ""
|
|
109
|
+
terminal.print("#{SYNC_START}#{up}#{lines}#{below}#{SYNC_END}")
|
|
110
|
+
@drawn = drawn.size
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def headline(width)
|
|
114
|
+
return super unless live?
|
|
115
|
+
|
|
116
|
+
finished = state != :running
|
|
117
|
+
bar = full_meter(current, total, started, HEADLINE_COLOR, counts, (@seconds if finished))
|
|
118
|
+
"#{glyph(state)} #{title.ljust(width)} #{bar}#{finished ? noted(headline_note) : stopping}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def row(job, width)
|
|
122
|
+
return super unless ENDED.include?(job.state) && !spinner?(job) && live?
|
|
123
|
+
|
|
124
|
+
handle = job.handle
|
|
125
|
+
bar = full_meter(handle.current, handle.total, job.started, handle.color, handle.counts, job.seconds.to_f)
|
|
126
|
+
"#{glyph(job.state)} #{job.label.ljust(width)} #{bar}#{noted(note(job))}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# {#meter}, with the time taken in place of the ETA once seconds are known.
|
|
130
|
+
def full_meter(done, all, since, color, tally, seconds)
|
|
131
|
+
return meter(done, all, since, color, tally) if seconds.nil?
|
|
132
|
+
|
|
133
|
+
took = Dry::CLI::UI::Duration.format(seconds)
|
|
134
|
+
Dry::CLI::UI::Widgets::Progress.meter(terminal.pastel,
|
|
135
|
+
config,
|
|
136
|
+
done: done,
|
|
137
|
+
all: all,
|
|
138
|
+
columns: bar_columns,
|
|
139
|
+
eta: took,
|
|
140
|
+
color: color,
|
|
141
|
+
counts: tally,
|
|
142
|
+
count_width: count_width).sub("ETA #{took}", "in #{took}")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module CLI
|
|
6
|
+
# Turns a saved JSON run into a standalone HTML report without calling a model.
|
|
7
|
+
class Render < Command
|
|
8
|
+
desc "Render a saved JSON eval report as standalone HTML"
|
|
9
|
+
|
|
10
|
+
argument :input, required: true, desc: "JSON report file produced by run or compare --format json"
|
|
11
|
+
option :output, aliases: ["-o"], desc: "HTML output path (default: log/<project>-llm-evals.report.html)"
|
|
12
|
+
|
|
13
|
+
def call(input:, output: nil, **)
|
|
14
|
+
source = File.read(input)
|
|
15
|
+
saved = LLM::Evals::Render::Reports::JSON.decode(source, providers: configuration.providers)
|
|
16
|
+
default = output.nil? || output.empty?
|
|
17
|
+
path = output == "-" || default ? nil : invocation.report_path(output)
|
|
18
|
+
pipeline = LLM::Evals::Render::Reports::Pipeline.new(providers: configuration.providers,
|
|
19
|
+
runtime: configuration.runtime,
|
|
20
|
+
destination: File.dirname(path || input),
|
|
21
|
+
temp_dir: File.dirname(input),
|
|
22
|
+
project: saved.run_options.dig("effective", "project") || saved.suite)
|
|
23
|
+
path = pipeline.report_path(File.dirname(invocation.report_path(""))) if default
|
|
24
|
+
document = pipeline.write_html(json: source, path:)
|
|
25
|
+
out.write(document) unless path
|
|
26
|
+
status_box(:success, "HTML report written to #{relative(path)}", stream: err) if path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def invocation = ui.send(:invocation)
|
|
32
|
+
|
|
33
|
+
def relative(path) = Pathname(path).relative_path_from(Dir.pwd).to_s
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|