llm-evals 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +26 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +764 -0
  5. data/exe/evals +9 -0
  6. data/exe/llm-evals +14 -0
  7. data/lib/llm/evals/adapter.rb +47 -0
  8. data/lib/llm/evals/case.rb +36 -0
  9. data/lib/llm/evals/cli/cases.rb +48 -0
  10. data/lib/llm/evals/cli/command.rb +163 -0
  11. data/lib/llm/evals/cli/compare.rb +40 -0
  12. data/lib/llm/evals/cli/formatting.rb +16 -0
  13. data/lib/llm/evals/cli/leaderboard.rb +183 -0
  14. data/lib/llm/evals/cli/model_colors.rb +23 -0
  15. data/lib/llm/evals/cli/model_command.rb +437 -0
  16. data/lib/llm/evals/cli/progress_bars.rb +147 -0
  17. data/lib/llm/evals/cli/render.rb +37 -0
  18. data/lib/llm/evals/cli/run.rb +69 -0
  19. data/lib/llm/evals/cli/standings.rb +114 -0
  20. data/lib/llm/evals/cli/verify.rb +24 -0
  21. data/lib/llm/evals/cli/version.rb +15 -0
  22. data/lib/llm/evals/cli.rb +43 -0
  23. data/lib/llm/evals/completion.rb +11 -0
  24. data/lib/llm/evals/config.rb +198 -0
  25. data/lib/llm/evals/configuration.rb +173 -0
  26. data/lib/llm/evals/contender.rb +31 -0
  27. data/lib/llm/evals/discovery.rb +95 -0
  28. data/lib/llm/evals/experiment.rb +158 -0
  29. data/lib/llm/evals/helper.rb +98 -0
  30. data/lib/llm/evals/launcher.rb +37 -0
  31. data/lib/llm/evals/metric.rb +26 -0
  32. data/lib/llm/evals/model.rb +55 -0
  33. data/lib/llm/evals/providers/base.rb +93 -0
  34. data/lib/llm/evals/providers/catalog.rb +32 -0
  35. data/lib/llm/evals/providers/open_router.rb +64 -0
  36. data/lib/llm/evals/providers/registry.rb +112 -0
  37. data/lib/llm/evals/providers/type_safe.rb +53 -0
  38. data/lib/llm/evals/render/reports/html.rb +266 -0
  39. data/lib/llm/evals/render/reports/json.rb +172 -0
  40. data/lib/llm/evals/render/reports/pipeline.rb +153 -0
  41. data/lib/llm/evals/render/reports/terminal.rb +46 -0
  42. data/lib/llm/evals/reply.rb +18 -0
  43. data/lib/llm/evals/run_result.rb +14 -0
  44. data/lib/llm/evals/runtime.rb +74 -0
  45. data/lib/llm/evals/schema_validator.rb +84 -0
  46. data/lib/llm/evals/score.rb +26 -0
  47. data/lib/llm/evals/scorer.rb +45 -0
  48. data/lib/llm/evals/templates/html_report.css +206 -0
  49. data/lib/llm/evals/templates/html_report.html.erb +184 -0
  50. data/lib/llm/evals/templates/html_report.js +48 -0
  51. data/lib/llm/evals/testing/echo_adapter.rb +29 -0
  52. data/lib/llm/evals/testing/fake_model.rb +18 -0
  53. data/lib/llm/evals/testing/fake_provider.rb +67 -0
  54. data/lib/llm/evals/tracker.rb +80 -0
  55. data/lib/llm/evals/trackers/braintrust.rb +91 -0
  56. data/lib/llm/evals/trackers/local.rb +78 -0
  57. data/lib/llm/evals/trackers/logfire.rb +143 -0
  58. data/lib/llm/evals/trackers/registry.rb +82 -0
  59. data/lib/llm/evals/usage.rb +33 -0
  60. data/lib/llm/evals/verify.rb +49 -0
  61. data/lib/llm/evals/version.rb +7 -0
  62. data/lib/llm/evals/warnings.rb +98 -0
  63. data/lib/llm/evals/weighted_score.rb +116 -0
  64. data/lib/llm/evals.rb +83 -0
  65. metadata +274 -0
@@ -0,0 +1,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