llm-evals 0.4.0 → 0.5.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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +163 -87
  4. data/exe/llm-evals +60 -5
  5. data/lib/.DS_Store +0 -0
  6. data/lib/llm/.DS_Store +0 -0
  7. data/lib/llm/evals/actions/adapter.rb +50 -0
  8. data/lib/llm/evals/actions/error_budget.rb +63 -0
  9. data/lib/llm/evals/actions/error_diagnosis.rb +51 -0
  10. data/lib/llm/evals/actions/error_streak.rb +83 -0
  11. data/lib/llm/evals/actions/experiment.rb +251 -0
  12. data/lib/llm/evals/actions/verify.rb +61 -0
  13. data/lib/llm/evals/actions.rb +9 -0
  14. data/lib/llm/evals/cli/cases.rb +10 -0
  15. data/lib/llm/evals/cli/command.rb +32 -99
  16. data/lib/llm/evals/cli/compare.rb +0 -20
  17. data/lib/llm/evals/cli/contender_selector.rb +61 -0
  18. data/lib/llm/evals/cli/error_pause.rb +81 -0
  19. data/lib/llm/evals/cli/experiment_pool.rb +97 -0
  20. data/lib/llm/evals/cli/formatting.rb +8 -0
  21. data/lib/llm/evals/cli/leaderboard.rb +66 -12
  22. data/lib/llm/evals/cli/model_colors.rb +3 -0
  23. data/lib/llm/evals/cli/model_command.rb +152 -315
  24. data/lib/llm/evals/cli/model_options.rb +80 -0
  25. data/lib/llm/evals/cli/option_groups.rb +32 -0
  26. data/lib/llm/evals/cli/progress_bars.rb +92 -5
  27. data/lib/llm/evals/cli/render.rb +6 -6
  28. data/lib/llm/evals/cli/report_dispatcher/console.rb +65 -0
  29. data/lib/llm/evals/cli/report_dispatcher/html_output.rb +50 -0
  30. data/lib/llm/evals/cli/report_dispatcher/json_output.rb +29 -0
  31. data/lib/llm/evals/cli/report_dispatcher/output.rb +56 -0
  32. data/lib/llm/evals/cli/report_dispatcher/pipeline_output.rb +38 -0
  33. data/lib/llm/evals/cli/report_dispatcher.rb +133 -0
  34. data/lib/llm/evals/cli/run.rb +13 -22
  35. data/lib/llm/evals/cli/run_planner.rb +97 -0
  36. data/lib/llm/evals/cli/run_record.rb +68 -0
  37. data/lib/llm/evals/cli/standings.rb +39 -17
  38. data/lib/llm/evals/cli/status_box.rb +51 -0
  39. data/lib/llm/evals/cli/table.rb +103 -0
  40. data/lib/llm/evals/cli/text.rb +22 -0
  41. data/lib/llm/evals/cli/tracker_resolver.rb +52 -0
  42. data/lib/llm/evals/cli/upgrade_config.rb +60 -0
  43. data/lib/llm/evals/cli/verify.rb +5 -1
  44. data/lib/llm/evals/cli/version.rb +4 -0
  45. data/lib/llm/evals/cli/weights.rb +48 -0
  46. data/lib/llm/evals/cli.rb +39 -6
  47. data/lib/llm/evals/config/contender_builder.rb +30 -0
  48. data/lib/llm/evals/config/error_limits.rb +41 -0
  49. data/lib/llm/evals/config/listing.rb +153 -0
  50. data/lib/llm/evals/config/results.rb +42 -0
  51. data/lib/llm/evals/config/score_weights.rb +25 -0
  52. data/lib/llm/evals/config/upgrade.rb +107 -0
  53. data/lib/llm/evals/config.rb +90 -79
  54. data/lib/llm/evals/datasets/case.rb +46 -0
  55. data/lib/llm/evals/datasets/discovery.rb +143 -0
  56. data/lib/llm/evals/datasets.rb +9 -0
  57. data/lib/llm/evals/launcher.rb +57 -3
  58. data/lib/llm/evals/models/contender.rb +51 -0
  59. data/lib/llm/evals/models/model.rb +87 -0
  60. data/lib/llm/evals/models/throttle.rb +170 -0
  61. data/lib/llm/evals/models.rb +9 -0
  62. data/lib/llm/evals/providers/base.rb +61 -16
  63. data/lib/llm/evals/providers/catalog.rb +5 -2
  64. data/lib/llm/evals/providers/open_router.rb +54 -7
  65. data/lib/llm/evals/providers/registry.rb +49 -16
  66. data/lib/llm/evals/providers/type_safe/client.rb +67 -0
  67. data/lib/llm/evals/providers/type_safe.rb +113 -12
  68. data/lib/llm/evals/providers.rb +9 -0
  69. data/lib/llm/evals/render/reports/call_stats_table.rb +44 -0
  70. data/lib/llm/evals/render/reports/html/dataset_section.rb +51 -0
  71. data/lib/llm/evals/render/reports/html/format.rb +38 -0
  72. data/lib/llm/evals/render/reports/html/global_standings.rb +41 -0
  73. data/lib/llm/evals/render/reports/html/ranking.rb +86 -0
  74. data/lib/llm/evals/render/reports/html/view_model.rb +116 -0
  75. data/lib/llm/evals/render/reports/html.rb +60 -181
  76. data/lib/llm/evals/render/reports/json/decoder.rb +137 -0
  77. data/lib/llm/evals/render/reports/json/encoder.rb +106 -0
  78. data/lib/llm/evals/render/reports/json.rb +38 -141
  79. data/lib/llm/evals/render/reports/pipeline.rb +55 -20
  80. data/lib/llm/evals/render/reports/terminal.rb +8 -1
  81. data/lib/llm/evals/render.rb +9 -0
  82. data/lib/llm/evals/results/provider_response.rb +25 -0
  83. data/lib/llm/evals/results/reply.rb +38 -0
  84. data/lib/llm/evals/results/run_outcome.rb +15 -0
  85. data/lib/llm/evals/results/run_result.rb +40 -0
  86. data/lib/llm/evals/results/score.rb +45 -0
  87. data/lib/llm/evals/results/warnings.rb +149 -0
  88. data/lib/llm/evals/results.rb +9 -0
  89. data/lib/llm/evals/runtime/configuration.rb +295 -0
  90. data/lib/llm/evals/runtime/environment.rb +90 -0
  91. data/lib/llm/evals/runtime/helper.rb +153 -0
  92. data/lib/llm/evals/runtime.rb +2 -67
  93. data/lib/llm/evals/scoring/metric.rb +47 -0
  94. data/lib/llm/evals/scoring/schema_validator.rb +102 -0
  95. data/lib/llm/evals/scoring/scorer.rb +49 -0
  96. data/lib/llm/evals/scoring/weighted_score.rb +148 -0
  97. data/lib/llm/evals/scoring.rb +9 -0
  98. data/lib/llm/evals/stats/call_stats.rb +91 -0
  99. data/lib/llm/evals/stats/usage.rb +46 -0
  100. data/lib/llm/evals/stats.rb +29 -0
  101. data/lib/llm/evals/testing/echo_adapter.rb +14 -9
  102. data/lib/llm/evals/testing/fake_model.rb +8 -6
  103. data/lib/llm/evals/testing/fake_provider.rb +39 -8
  104. data/lib/llm/evals/testing.rb +9 -0
  105. data/lib/llm/evals/trackers/base.rb +110 -0
  106. data/lib/llm/evals/trackers/braintrust.rb +40 -3
  107. data/lib/llm/evals/trackers/local.rb +38 -2
  108. data/lib/llm/evals/trackers/logfire.rb +56 -3
  109. data/lib/llm/evals/trackers/registry.rb +29 -7
  110. data/lib/llm/evals/trackers.rb +9 -0
  111. data/lib/llm/evals/version.rb +3 -1
  112. data/lib/llm/evals.rb +17 -7
  113. metadata +91 -24
  114. data/lib/llm/evals/adapter.rb +0 -47
  115. data/lib/llm/evals/case.rb +0 -36
  116. data/lib/llm/evals/completion.rb +0 -11
  117. data/lib/llm/evals/configuration.rb +0 -173
  118. data/lib/llm/evals/contender.rb +0 -31
  119. data/lib/llm/evals/discovery.rb +0 -95
  120. data/lib/llm/evals/experiment.rb +0 -158
  121. data/lib/llm/evals/helper.rb +0 -98
  122. data/lib/llm/evals/metric.rb +0 -26
  123. data/lib/llm/evals/model.rb +0 -55
  124. data/lib/llm/evals/reply.rb +0 -18
  125. data/lib/llm/evals/run_result.rb +0 -14
  126. data/lib/llm/evals/schema_validator.rb +0 -84
  127. data/lib/llm/evals/score.rb +0 -26
  128. data/lib/llm/evals/scorer.rb +0 -45
  129. data/lib/llm/evals/tracker.rb +0 -80
  130. data/lib/llm/evals/usage.rb +0 -33
  131. data/lib/llm/evals/verify.rb +0 -49
  132. data/lib/llm/evals/warnings.rb +0 -98
  133. data/lib/llm/evals/weighted_score.rb +0 -116
  134. /data/lib/llm/evals/{templates → render/templates}/html_report.css +0 -0
  135. /data/lib/llm/evals/{templates → render/templates}/html_report.html.erb +0 -0
  136. /data/lib/llm/evals/{templates → render/templates}/html_report.js +0 -0
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "pathname"
5
- require "uri"
6
4
 
7
5
  module LLM
8
6
  module Evals
@@ -11,374 +9,205 @@ module LLM
11
9
  # flag first, then the config entry, then .llm-evals.config.yml's defaults.
12
10
  #
13
11
  # 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}.
12
+ # their glossary and the ranking come from the suite's {Scoring::Scorer#metrics}.
15
13
  class ModelCommand < Command
16
14
  include Formatting
17
15
 
16
+ # Gives each subclass the model flags, since dry-cli starts every subclass with no options.
17
+ #
18
+ # @param subclass [Class<ModelCommand>]
19
+ # @return [void]
18
20
  def self.inherited(subclass)
19
21
  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)"
22
+ ModelOptions.apply(subclass)
23
+ end
24
+
25
+ # The run every model command makes; subclasses add what prints per run
26
+ # through {#details}.
27
+ #
28
+ # @param models [Array<String>] as given to -m, possibly comma-separated
29
+ # @param dataset [Array<String>] as given to -d, possibly comma-separated
30
+ # @param dry_run [Boolean] print the matrix and spend nothing
31
+ # @param yes [Boolean] keep running through a streak of failures without asking
32
+ # @param options [Hash{Symbol => Object}] the other parsed flags
33
+ # @return [void]
34
+ # @raise [Error] when nothing can run, once the error budget is spent, or when told to stop
35
+ def call(models:, dataset:, dry_run:, yes: false, **options)
36
+ config = lineup(options)
37
+ weigh(config)
38
+ trackers = TrackerResolver.new(configuration:, config:, options:, err:)
39
+ planner = RunPlanner.new(configuration:, config:, options:, trackers:)
40
+ contenders = ContenderSelector.new(config:, providers:, warnings:).call(models, options)
41
+ datasets = planner.datasets(dataset)
42
+ return matrix(datasets, contenders) if dry_run
43
+
44
+ setup!(contenders, trackers)
45
+ runs = planner.experiments(datasets, contenders)
46
+ budget = config.error_budget
47
+ streak = Actions::ErrorStreak.new
48
+ pause = ErrorPause.new(console: ui, runs:, yes:, notice: ->(level, text) { status_box(level, text, stream: err) })
49
+ outcome = ExperimentPool.new(console: ui, colors: model_colors, warnings:)
50
+ .call(runs, concurrency: planner.concurrency, budget:, streak:, pause:)
51
+ record = RunRecord.new(command: invocation.action.to_s,
52
+ cli: { models:, dataset:, **options },
53
+ config:,
54
+ planner:,
55
+ trackers:,
56
+ providers:,
57
+ contenders:,
58
+ datasets:,
59
+ runs:,
60
+ seconds: outcome.seconds)
61
+ report(outcome.summaries,
62
+ output: options[:output],
63
+ open: options[:open],
64
+ scores: options[:scores],
65
+ format: options[:format],
66
+ config:,
67
+ options:,
68
+ run_options: record.to_h) { details(it) }
69
+ raise Error, budget.reason if budget&.spent?
70
+ raise Error, streak.reason if streak.stopped?
80
71
  end
81
72
 
82
73
  private
83
74
 
75
+ # What prints before the leaderboard; nothing, unless a subclass says.
76
+ #
77
+ # @param _summaries [Array<Actions::Experiment::Summary>]
78
+ # @return [void]
79
+ def details(_summaries) = nil
80
+
81
+ # A file named by --config may use the names of the suite's providers.
82
+ #
83
+ # @param options [Hash] the parsed flags
84
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
85
+ def lineup(options) = options[:config] ? Config.new(File.expand_path(options[:config]), providers:) : configuration.lineup
86
86
 
87
+ # The suite's, which every lineup and saved report is read against.
88
+ #
87
89
  # @return [Providers::Registry]
88
90
  def providers = configuration.providers
89
91
 
90
- # @return [Scorer]
92
+ # The suite's; it supplies the metrics until a saved snapshot replaces them.
93
+ #
94
+ # @return [Scoring::Scorer]
91
95
  def scorer = configuration.scorer
92
96
 
93
- # @return [Array<Metric>]
97
+ # The saved snapshot's once reporting has begun, so a report shows what was saved.
98
+ #
99
+ # @return [Array<Scoring::Metric>]
94
100
  def metrics = @report_metrics || scorer.metrics
95
101
 
96
102
  # Reads the file's weights before anything is spent, so a bad one fails
97
103
  # the run rather than the report at its end.
98
104
  #
99
- # @return [WeightedScore, nil] nil when the file sets none
105
+ # @param config [Config]
106
+ # @return [Scoring::WeightedScore, nil] nil when the file sets none
100
107
  # @raise [Error] on a bad weight
101
108
  def weigh(config)
102
- config.results # validate output settings before model calls start
109
+ config.validate!
103
110
  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
111
+ @weighted = weights.empty? ? nil : Scoring::WeightedScore.new(weights, metrics: metrics.map(&:name))
202
112
  end
203
113
 
204
114
  # 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))
115
+ #
116
+ # @param contenders [Array<Models::Contender>]
117
+ # @param trackers [TrackerResolver]
118
+ # @return [void]
119
+ # @raise [Error] when a needed variable or gem is missing
120
+ def setup!(contenders, trackers)
121
+ configuration.runtime.setup!(providers: providers.used_by(contenders), tracker: trackers.remote)
207
122
  providers.verify!(contenders)
208
123
  end
209
124
 
210
125
  # What -n prints: every experiment that would run, and where the
211
126
  # refusals went, if there were any.
127
+ #
128
+ # @param datasets [Array<String>]
129
+ # @param contenders [Array<Models::Contender>]
130
+ # @return [void]
212
131
  def matrix(datasets, contenders)
213
132
  rows = datasets.product(contenders).map { |name, c| [name, c.model, c.effort, c.temperature, c.max_tokens] }
214
133
  table(rows, header: %w[dataset model effort temp max_tokens])
215
134
  save_warnings
216
135
  end
217
136
 
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.
137
+ # Filled by the selector and the pool, and written out only by {#save_warnings}.
221
138
  #
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
139
+ # @return [Results::Warnings] what this command has held back from the screen
140
+ def warnings = @warnings ||= Results::Warnings.new
269
141
 
270
142
  # 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.
143
+ # the scores, when the run's display is the terminal; see {ReportDispatcher}.
273
144
  #
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>]
145
+ # @param summaries [Array<Actions::Experiment::Summary>]
278
146
  # @param output [String, nil] what -o was given: nil, "-", "" for the default file, or a path
279
147
  # @param open [Boolean]
280
148
  # @param scores [Boolean] also print every experiment by score
281
- # @param format [String] `tui`, `html` or `json`
149
+ # @param format [String, nil] `tui`, `html` or `json`
150
+ # @param config [Config]
151
+ # @param options [Hash] the parsed flags
152
+ # @param run_options [Hash] what the saved report says about its run; see {RunRecord#to_h}
153
+ # @param details [Proc, nil] prints what comes before the leaderboard, given the summaries shown
282
154
  # @return [void]
283
155
  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|
156
+ dispatcher = report_dispatcher(config, options)
157
+ # A run that also writes the page opens that, not a second window on the tracker.
158
+ open_tracker = open && dispatcher.display(format) == "tui"
159
+ dispatcher.call(summaries, metrics:, weighted_score: @weighted, run_options:, output:, open:, format:) do |report|
331
160
  @report_metrics = report.metrics
332
161
  @weighted = report.weighted_score
333
- terminal_report(report.summaries, output:, open:, scores:, totals_mode: options[:totals]) do |shown|
162
+ terminal_report(report.summaries, output:, open: open_tracker, scores:, totals_mode: options[:totals], run_options:) do |shown|
334
163
  details&.call(shown)
335
164
  end
336
165
  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
166
  end
346
167
 
347
- def terminal_report(summaries, output:, open:, scores:, totals_mode:, &details)
168
+ # Hands over the command's streams only through a {ReportDispatcher::Console},
169
+ # so that no output ever sees the command.
170
+ #
171
+ # @param config [Config]
172
+ # @param options [Hash] the parsed flags
173
+ # @return [ReportDispatcher]
174
+ def report_dispatcher(config, options)
175
+ ReportDispatcher.new(config:,
176
+ options:,
177
+ providers:,
178
+ runtime: configuration.runtime,
179
+ suite: configuration.name,
180
+ invocation:,
181
+ console: ReportDispatcher::Console.new(out:,
182
+ browser: configuration.runtime,
183
+ save_warnings: method(:save_warnings),
184
+ status: ->(level, text) { status_box(level, text, stream: err) }))
185
+ end
186
+
187
+ # Draws the terminal view, says where a report file went, saves the warnings,
188
+ # and opens the best experiment on its tracker.
189
+ #
190
+ # @param summaries [Array<Actions::Experiment::Summary>]
191
+ # @param output [String, nil] what -o was given
192
+ # @param open [Boolean] open the best experiment's tracker page
193
+ # @param scores [Boolean] also print every experiment by score
194
+ # @param totals_mode [String] `overall`, `variant` or `both`
195
+ # @param run_options [Hash] what the call statistics are read from
196
+ # @param details [Proc, nil] prints what comes before the statistics, given the summaries shown
197
+ # @return [void]
198
+ def terminal_report(summaries, output:, open:, scores:, totals_mode:, run_options:, &details)
348
199
  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)
200
+ path = terminal.call(summaries:, output:, scores:, totals_mode:) do |shown|
201
+ details&.call(shown)
202
+ stats = LLM::Evals::Render::Reports::CallStatsTable.new(Stats::CallStats.for_run(shown, run_options))
203
+ table(stats.rows, header: LLM::Evals::Render::Reports::CallStatsTable::HEADER, fill: 0)
204
+ end
350
205
  status_box(:info, "Report written to #{relative(path)}", stream: err) if path
351
206
  save_warnings
352
207
  url = summaries.max_by { it.score || -1 }&.permalink
353
208
  status_box(:warning, "could not open #{url}", stream: err) if open && url && !configuration.runtime.browse(url)
354
209
  end
355
210
 
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
211
  # Saves {#warnings}, if there are any, to
383
212
  # log/<command>.warnings.<YYYY-MM-DD>.<HHMMSS>.log at the repository
384
213
  # root, stamped with the time the process started, and says how many.
@@ -395,15 +224,12 @@ module LLM
395
224
  status_box(:warning, "#{count} #{count == 1 ? "Warning was" : "Warnings were"} recorded in #{relative(path)}", stream: err)
396
225
  end
397
226
 
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
227
  # Points the tables and boxes at another stream while the block runs.
404
228
  # They keep the screen's width.
405
229
  #
406
- # @param io [IO, nil] nil to leave them on the screen
230
+ # @param io [IO, Dry::CLI::UI::Report, nil] nil to leave them on the screen
231
+ # @yield with the stream swapped
232
+ # @return [Object] what the block returns
407
233
  def writing_to(io)
408
234
  return yield if io.nil?
409
235
 
@@ -420,12 +246,23 @@ module LLM
420
246
  end
421
247
  end
422
248
 
249
+ # A snapshot's metrics may name their own primary; otherwise the scorer's stands.
250
+ #
251
+ # @return [Scoring::Metric]
423
252
  def primary = metrics.find(&:primary) || scorer.primary
424
253
 
254
+ # One per command, so the bars and the tables agree.
255
+ #
256
+ # @return [ModelColors]
425
257
  def model_colors = @model_colors ||= ModelColors.new
426
258
 
427
259
  # Built per call: {#report} swaps the metrics and the weights for those of
428
260
  # the saved snapshot, and {#writing_to} swaps the stream, before this runs.
261
+ #
262
+ # @param summaries [Array<Actions::Experiment::Summary>]
263
+ # @param scores [Boolean] also print every experiment by score
264
+ # @param totals_mode [String] `overall`, `variant` or `both`
265
+ # @return [void]
429
266
  def leaderboard(summaries, scores: false, totals_mode: "overall")
430
267
  standings = Standings.new(metrics:, primary:, weighted: @weighted)
431
268
  Leaderboard.new(standings:, table: method(:table), out:, pastel:, colors: model_colors)
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module CLI
6
+ # The flags of every command that calls a model.
7
+ module ModelOptions
8
+ # Declares them on the command's class, which is where dry-cli keeps flags.
9
+ #
10
+ # @param command [Class<Command>]
11
+ # @return [void]
12
+ def self.apply(command)
13
+ command.extend(Dry::CLI::UI::Flags)
14
+ command.flags(:dry_run, :yes, :output)
15
+ command.option :models,
16
+ type: :array,
17
+ aliases: ["-m"],
18
+ default: [],
19
+ desc: "Model ids, comma-separated, instead of the config file's vendors"
20
+ command.option :dataset,
21
+ type: :array,
22
+ aliases: ["-d"],
23
+ default: [],
24
+ desc: "Datasets, comma-separated; every dataset when omitted. Each model runs on each"
25
+ command.option :config, aliases: ["-C"], desc: "The lineup file (default: the suite's .llm-evals.config.yml)"
26
+ command.option :effort, aliases: ["-e"], desc: "Reasoning effort: default, or one the provider accepts (overrides the config file)"
27
+ command.option :temperature, aliases: ["-t"], desc: "Sampling temperature, or 'default' to send none (overrides the config file)"
28
+ command.option :max_tokens, desc: "Output token cap, reasoning included (overrides the config file)"
29
+ command.option :case,
30
+ type: :array,
31
+ aliases: ["-c"],
32
+ default: [],
33
+ desc: "Only these cases, by id prefix: -c clear or -c clear/003,vague/001"
34
+ command.option :parallelism, aliases: ["-p"], desc: "Cases in flight at once, per model (default: the config's defaults.parallelism)"
35
+ command.option :concurrency,
36
+ aliases: ["-j"],
37
+ desc: "Experiments running at once; 0 runs them all together (default: the number of CPUs)"
38
+ command.option :project,
39
+ desc: "Braintrust project name; default is the BRAINTRUST_PROJECT_ID project, else the config file's project"
40
+ command.option :open,
41
+ type: :boolean,
42
+ default: true,
43
+ group: :browser,
44
+ desc: "Open the HTML report when done; with --display tui, the best experiment on its tracker instead"
45
+ command.option :scores,
46
+ type: :boolean,
47
+ default: false,
48
+ group: :tui,
49
+ desc: "Also print every experiment by score, with its cost and its tracker's link"
50
+ command.option :totals,
51
+ values: %w[overall variant both],
52
+ default: "overall",
53
+ group: :tui,
54
+ desc: "Group TUI totals by overall results, case variant or both"
55
+ command.option :format,
56
+ values: %w[tui html json],
57
+ desc: "Override the configured display: tui, html or json"
58
+ command.option :display,
59
+ values: Config::RESULT_DISPLAYS,
60
+ desc: "Override results.display: tui skips the HTML report, browser skips the terminal summary"
61
+ command.option :autosave,
62
+ type: :boolean,
63
+ default: nil,
64
+ desc: "Save a JSON snapshot regardless of results.autosave"
65
+ command.option :destination,
66
+ desc: "Override results.destination"
67
+ command.option :temp_dir,
68
+ desc: "Override results.temp_dir"
69
+ command.option :local,
70
+ type: :boolean,
71
+ default: false,
72
+ desc: "No remote tracker: results go to log/ only (still calls the models)"
73
+ command.option :tracker,
74
+ desc: "The remote tracker: braintrust, logfire, none, or one the suite registers " \
75
+ "(default: the config's tracker:, else the one whose key is in the environment)"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end