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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +163 -87
- data/exe/llm-evals +60 -5
- data/lib/.DS_Store +0 -0
- data/lib/llm/.DS_Store +0 -0
- data/lib/llm/evals/actions/adapter.rb +50 -0
- data/lib/llm/evals/actions/error_budget.rb +63 -0
- data/lib/llm/evals/actions/error_diagnosis.rb +51 -0
- data/lib/llm/evals/actions/error_streak.rb +83 -0
- data/lib/llm/evals/actions/experiment.rb +251 -0
- data/lib/llm/evals/actions/verify.rb +61 -0
- data/lib/llm/evals/actions.rb +9 -0
- data/lib/llm/evals/cli/cases.rb +10 -0
- data/lib/llm/evals/cli/command.rb +32 -99
- data/lib/llm/evals/cli/compare.rb +0 -20
- data/lib/llm/evals/cli/contender_selector.rb +61 -0
- data/lib/llm/evals/cli/error_pause.rb +81 -0
- data/lib/llm/evals/cli/experiment_pool.rb +97 -0
- data/lib/llm/evals/cli/formatting.rb +8 -0
- data/lib/llm/evals/cli/leaderboard.rb +66 -12
- data/lib/llm/evals/cli/model_colors.rb +3 -0
- data/lib/llm/evals/cli/model_command.rb +152 -315
- data/lib/llm/evals/cli/model_options.rb +80 -0
- data/lib/llm/evals/cli/option_groups.rb +32 -0
- data/lib/llm/evals/cli/progress_bars.rb +92 -5
- data/lib/llm/evals/cli/render.rb +6 -6
- data/lib/llm/evals/cli/report_dispatcher/console.rb +65 -0
- data/lib/llm/evals/cli/report_dispatcher/html_output.rb +50 -0
- data/lib/llm/evals/cli/report_dispatcher/json_output.rb +29 -0
- data/lib/llm/evals/cli/report_dispatcher/output.rb +56 -0
- data/lib/llm/evals/cli/report_dispatcher/pipeline_output.rb +38 -0
- data/lib/llm/evals/cli/report_dispatcher.rb +133 -0
- data/lib/llm/evals/cli/run.rb +13 -22
- data/lib/llm/evals/cli/run_planner.rb +97 -0
- data/lib/llm/evals/cli/run_record.rb +68 -0
- data/lib/llm/evals/cli/standings.rb +39 -17
- data/lib/llm/evals/cli/status_box.rb +51 -0
- data/lib/llm/evals/cli/table.rb +103 -0
- data/lib/llm/evals/cli/text.rb +22 -0
- data/lib/llm/evals/cli/tracker_resolver.rb +52 -0
- data/lib/llm/evals/cli/upgrade_config.rb +60 -0
- data/lib/llm/evals/cli/verify.rb +5 -1
- data/lib/llm/evals/cli/version.rb +4 -0
- data/lib/llm/evals/cli/weights.rb +48 -0
- data/lib/llm/evals/cli.rb +39 -6
- data/lib/llm/evals/config/contender_builder.rb +30 -0
- data/lib/llm/evals/config/error_limits.rb +41 -0
- data/lib/llm/evals/config/listing.rb +153 -0
- data/lib/llm/evals/config/results.rb +42 -0
- data/lib/llm/evals/config/score_weights.rb +25 -0
- data/lib/llm/evals/config/upgrade.rb +107 -0
- data/lib/llm/evals/config.rb +90 -79
- data/lib/llm/evals/datasets/case.rb +46 -0
- data/lib/llm/evals/datasets/discovery.rb +143 -0
- data/lib/llm/evals/datasets.rb +9 -0
- data/lib/llm/evals/launcher.rb +57 -3
- data/lib/llm/evals/models/contender.rb +51 -0
- data/lib/llm/evals/models/model.rb +87 -0
- data/lib/llm/evals/models/throttle.rb +170 -0
- data/lib/llm/evals/models.rb +9 -0
- data/lib/llm/evals/providers/base.rb +61 -16
- data/lib/llm/evals/providers/catalog.rb +5 -2
- data/lib/llm/evals/providers/open_router.rb +54 -7
- data/lib/llm/evals/providers/registry.rb +49 -16
- data/lib/llm/evals/providers/type_safe/client.rb +67 -0
- data/lib/llm/evals/providers/type_safe.rb +113 -12
- data/lib/llm/evals/providers.rb +9 -0
- data/lib/llm/evals/render/reports/call_stats_table.rb +44 -0
- data/lib/llm/evals/render/reports/html/dataset_section.rb +51 -0
- data/lib/llm/evals/render/reports/html/format.rb +38 -0
- data/lib/llm/evals/render/reports/html/global_standings.rb +41 -0
- data/lib/llm/evals/render/reports/html/ranking.rb +86 -0
- data/lib/llm/evals/render/reports/html/view_model.rb +116 -0
- data/lib/llm/evals/render/reports/html.rb +60 -181
- data/lib/llm/evals/render/reports/json/decoder.rb +137 -0
- data/lib/llm/evals/render/reports/json/encoder.rb +106 -0
- data/lib/llm/evals/render/reports/json.rb +38 -141
- data/lib/llm/evals/render/reports/pipeline.rb +55 -20
- data/lib/llm/evals/render/reports/terminal.rb +8 -1
- data/lib/llm/evals/render.rb +9 -0
- data/lib/llm/evals/results/provider_response.rb +25 -0
- data/lib/llm/evals/results/reply.rb +38 -0
- data/lib/llm/evals/results/run_outcome.rb +15 -0
- data/lib/llm/evals/results/run_result.rb +40 -0
- data/lib/llm/evals/results/score.rb +45 -0
- data/lib/llm/evals/results/warnings.rb +149 -0
- data/lib/llm/evals/results.rb +9 -0
- data/lib/llm/evals/runtime/configuration.rb +295 -0
- data/lib/llm/evals/runtime/environment.rb +90 -0
- data/lib/llm/evals/runtime/helper.rb +153 -0
- data/lib/llm/evals/runtime.rb +2 -67
- data/lib/llm/evals/scoring/metric.rb +47 -0
- data/lib/llm/evals/scoring/schema_validator.rb +102 -0
- data/lib/llm/evals/scoring/scorer.rb +49 -0
- data/lib/llm/evals/scoring/weighted_score.rb +148 -0
- data/lib/llm/evals/scoring.rb +9 -0
- data/lib/llm/evals/stats/call_stats.rb +91 -0
- data/lib/llm/evals/stats/usage.rb +46 -0
- data/lib/llm/evals/stats.rb +29 -0
- data/lib/llm/evals/testing/echo_adapter.rb +14 -9
- data/lib/llm/evals/testing/fake_model.rb +8 -6
- data/lib/llm/evals/testing/fake_provider.rb +39 -8
- data/lib/llm/evals/testing.rb +9 -0
- data/lib/llm/evals/trackers/base.rb +110 -0
- data/lib/llm/evals/trackers/braintrust.rb +40 -3
- data/lib/llm/evals/trackers/local.rb +38 -2
- data/lib/llm/evals/trackers/logfire.rb +56 -3
- data/lib/llm/evals/trackers/registry.rb +29 -7
- data/lib/llm/evals/trackers.rb +9 -0
- data/lib/llm/evals/version.rb +3 -1
- data/lib/llm/evals.rb +17 -7
- metadata +91 -24
- data/lib/llm/evals/adapter.rb +0 -47
- data/lib/llm/evals/case.rb +0 -36
- data/lib/llm/evals/completion.rb +0 -11
- data/lib/llm/evals/configuration.rb +0 -173
- data/lib/llm/evals/contender.rb +0 -31
- data/lib/llm/evals/discovery.rb +0 -95
- data/lib/llm/evals/experiment.rb +0 -158
- data/lib/llm/evals/helper.rb +0 -98
- data/lib/llm/evals/metric.rb +0 -26
- data/lib/llm/evals/model.rb +0 -55
- data/lib/llm/evals/reply.rb +0 -18
- data/lib/llm/evals/run_result.rb +0 -14
- data/lib/llm/evals/schema_validator.rb +0 -84
- data/lib/llm/evals/score.rb +0 -26
- data/lib/llm/evals/scorer.rb +0 -45
- data/lib/llm/evals/tracker.rb +0 -80
- data/lib/llm/evals/usage.rb +0 -33
- data/lib/llm/evals/verify.rb +0 -49
- data/lib/llm/evals/warnings.rb +0 -98
- data/lib/llm/evals/weighted_score.rb +0 -116
- /data/lib/llm/evals/{templates → render/templates}/html_report.css +0 -0
- /data/lib/llm/evals/{templates → render/templates}/html_report.html.erb +0 -0
- /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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
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.
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
223
|
-
|
|
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
|
|
272
|
-
# best-scoring experiment.
|
|
143
|
+
# the scores, when the run's display is the terminal; see {ReportDispatcher}.
|
|
273
144
|
#
|
|
274
|
-
#
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|