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
data/lib/llm/evals/config.rb
CHANGED
|
@@ -24,41 +24,79 @@ module LLM
|
|
|
24
24
|
# autosave: true # completed runs are saved as JSON (default)
|
|
25
25
|
# destination: ./evals/results/
|
|
26
26
|
# temp_dir: ./tmp # temporary JSON used when autosave is off
|
|
27
|
-
# display:
|
|
28
|
-
#
|
|
27
|
+
# display: both # none, tui, browser or both (default)
|
|
28
|
+
# errors: # optional: when to give up; see {Actions::ErrorBudget}
|
|
29
|
+
# abort_after:
|
|
30
|
+
# total: 1000
|
|
31
|
+
# percent: 60
|
|
32
|
+
# weighted_score: # optional: what matters most; see {Scoring::WeightedScore}
|
|
29
33
|
# weights:
|
|
30
34
|
# score: 0.6
|
|
31
35
|
# cost: -0.4
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
36
|
+
# openrouter: # a provider's name; this one needs OPENROUTER_API_KEY
|
|
37
|
+
# rate_limit: { burst: 1000, per_minute: 500 }
|
|
38
|
+
# vendors:
|
|
39
|
+
# anthropic:
|
|
40
|
+
# rate_limit: { burst: 10, per_minute: 50 }
|
|
41
|
+
# models:
|
|
42
|
+
# - model: claude-haiku-5.5
|
|
43
|
+
# rate_limit: { burst: 5, per_minute: 20 }
|
|
44
|
+
# effort: [default, low] # a list expands into one contender per value
|
|
45
|
+
# typesafe: # a provider with no vendors; this one needs TYPESAFE_API_KEY
|
|
46
|
+
# models:
|
|
47
|
+
# - jev-1.14.0 # a bare name is the same as `- model: jev-1.14.0`
|
|
39
48
|
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
49
|
+
# An entry under `vendors:` becomes "<vendor>/<model>", the id a provider
|
|
50
|
+
# recognises; one under `models:` becomes the provider's prefix and the name.
|
|
51
|
+
# A call waits on every `rate_limit` that matches it; see {Models::Throttle}.
|
|
42
52
|
class Config
|
|
43
53
|
# The request types a suite may declare. `prompt` is a chat completion:
|
|
44
54
|
# system and user text in, text out. `typesafe` is TypeSafe's typed
|
|
45
55
|
# question API, whose request shape the first such suite will define.
|
|
46
56
|
REQUEST_TYPES = %w[prompt typesafe].freeze
|
|
47
57
|
|
|
58
|
+
# The settings a contender gets when neither the file's `defaults:` nor its entry says.
|
|
48
59
|
BUILTIN = {
|
|
49
60
|
"effort" => "default", "temperature" => 0.2, "max_tokens" => 4096, "parallelism" => 4
|
|
50
61
|
}.freeze
|
|
51
62
|
|
|
52
|
-
|
|
53
|
-
RESULT_DISPLAYS = %w[none tui browser].freeze
|
|
63
|
+
# The values `results.display` accepts.
|
|
64
|
+
RESULT_DISPLAYS = %w[none tui browser both].freeze
|
|
54
65
|
|
|
55
|
-
|
|
66
|
+
# The displays that draw in the terminal, and those that write the HTML report.
|
|
67
|
+
TERMINAL_DISPLAYS = %w[tui both].freeze
|
|
68
|
+
# The displays that write the HTML report.
|
|
69
|
+
BROWSER_DISPLAYS = %w[browser both].freeze
|
|
70
|
+
|
|
71
|
+
# What a lineup that says nothing gets: the terminal summary, and the HTML report opened in the browser.
|
|
72
|
+
DEFAULT_DISPLAY = "both"
|
|
73
|
+
|
|
74
|
+
attr_reader :path, :providers
|
|
75
|
+
|
|
76
|
+
# @!attribute [r] path
|
|
77
|
+
# The lineup file, named in every error, whether or not it exists.
|
|
78
|
+
# @return [String]
|
|
79
|
+
|
|
80
|
+
# @!attribute [r] providers
|
|
81
|
+
# Whose names the file's sections may use.
|
|
82
|
+
# @return [Providers::Registry]
|
|
83
|
+
|
|
84
|
+
# Reads every section a run needs before anything is spent, so a bad one
|
|
85
|
+
# fails the run rather than the report at its end.
|
|
86
|
+
#
|
|
87
|
+
# @return [void]
|
|
88
|
+
# @raise [Error] on the first bad section
|
|
89
|
+
def validate!
|
|
90
|
+
results
|
|
91
|
+
error_budget
|
|
92
|
+
score_weights
|
|
93
|
+
end
|
|
56
94
|
|
|
57
95
|
# @param path [String] read when it exists; an absent file is an empty lineup
|
|
58
|
-
# @param
|
|
59
|
-
def initialize(path,
|
|
96
|
+
# @param providers [Providers::Registry] whose names the file's sections may use
|
|
97
|
+
def initialize(path, providers: Providers::Registry.default)
|
|
60
98
|
@path = path
|
|
61
|
-
@
|
|
99
|
+
@providers = providers
|
|
62
100
|
@data = File.exist?(path) ? (YAML.safe_load_file(path) || {}) : {}
|
|
63
101
|
end
|
|
64
102
|
|
|
@@ -79,8 +117,8 @@ module LLM
|
|
|
79
117
|
|
|
80
118
|
# A tracker's own section of the file, e.g. `logfire: { include_replies: true }`.
|
|
81
119
|
#
|
|
82
|
-
# @param name [String]
|
|
83
|
-
# @return [Hash]
|
|
120
|
+
# @param name [String] the tracker's name, which is also its section's key
|
|
121
|
+
# @return [Hash{String => Object}] empty when the file has no such mapping
|
|
84
122
|
def tracker_options(name) = (@data[name.to_s].is_a?(Hash) ? @data[name.to_s] : {}).transform_keys(&:to_s)
|
|
85
123
|
|
|
86
124
|
# @return [Array<String>] the `require:` key, one path or a list
|
|
@@ -112,87 +150,60 @@ module LLM
|
|
|
112
150
|
# @return [Hash{String => Object}] the built-in defaults under the file's own
|
|
113
151
|
def defaults = BUILTIN.merge(@data.fetch("defaults", nil) || {})
|
|
114
152
|
|
|
115
|
-
# Where completed runs are saved and how they are presented. Paths are
|
|
116
|
-
# rooted at the caller's working directory so the same config works from
|
|
117
|
-
# a project root or when invoked by a task runner.
|
|
118
|
-
#
|
|
119
153
|
# @return [Results]
|
|
120
154
|
# @raise [Error] when a setting has the wrong type or an unknown display
|
|
121
|
-
def results
|
|
122
|
-
section = @data.fetch("results", {})
|
|
123
|
-
raise Error, "#{path}: results must be a mapping" unless section.is_a?(Hash)
|
|
124
|
-
|
|
125
|
-
autosave = section.fetch("autosave", true)
|
|
126
|
-
unless [true, false].include?(autosave)
|
|
127
|
-
raise Error, "#{path}: results.autosave must be true or false"
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
display = section.fetch("display", "tui").to_s
|
|
131
|
-
unless RESULT_DISPLAYS.include?(display)
|
|
132
|
-
raise Error, "#{path}: results.display must be one of #{RESULT_DISPLAYS.join(", ")}"
|
|
133
|
-
end
|
|
155
|
+
def results = Results.from_hash(@data.fetch("results", {}), path:)
|
|
134
156
|
|
|
135
|
-
|
|
136
|
-
temp_dir = section.fetch("temp_dir", "./tmp").to_s
|
|
137
|
-
Results.new(autosave:,
|
|
138
|
-
destination: File.expand_path(destination, Dir.pwd),
|
|
139
|
-
temp_dir: File.expand_path(temp_dir, Dir.pwd),
|
|
140
|
-
display_mode: display)
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
# @return [Hash{String => Object}] `weighted_score.weights`; see {WeightedScore}
|
|
157
|
+
# @return [Hash{String => Object}] `weighted_score.weights`; see {Scoring::WeightedScore}
|
|
144
158
|
# @raise [Error] when either level is not a mapping
|
|
145
|
-
def score_weights
|
|
146
|
-
section = @data["weighted_score"]
|
|
147
|
-
return {} if section.nil?
|
|
148
|
-
|
|
149
|
-
weights = section["weights"] if section.is_a?(Hash)
|
|
150
|
-
raise Error, "#{path}: weighted_score needs a weights: mapping of column to number" unless weights.is_a?(Hash)
|
|
159
|
+
def score_weights = ScoreWeights.from_hash(@data["weighted_score"], path:)
|
|
151
160
|
|
|
152
|
-
|
|
161
|
+
# When to give up on a run; see {Actions::ErrorBudget}.
|
|
162
|
+
#
|
|
163
|
+
# @return [Actions::ErrorBudget, nil] nil when the file sets no limit
|
|
164
|
+
# @raise [Error] on a limit that is missing or out of range
|
|
165
|
+
def error_budget
|
|
166
|
+
section = @data["errors"]
|
|
167
|
+
ErrorLimits.from_hash(section, path:).budget unless section.nil?
|
|
153
168
|
end
|
|
154
169
|
|
|
155
|
-
# Every vendor/model/effort/temperature combination the file lists,
|
|
156
|
-
#
|
|
170
|
+
# Every vendor/model/effort/temperature combination the file lists, in
|
|
171
|
+
# the file's order.
|
|
157
172
|
#
|
|
158
173
|
# @param overrides [Hash{String => Object}] values from the command line; nil entries are ignored
|
|
159
|
-
# @return [Array<Contender>]
|
|
160
|
-
# @raise [Error] on a
|
|
174
|
+
# @return [Array<Models::Contender>]
|
|
175
|
+
# @raise [Error] on a section or entry the file format does not have
|
|
161
176
|
def contenders(overrides = {})
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
direct = (@data["direct"] || {}).flat_map do |name, entries|
|
|
166
|
-
unless direct_providers.include?(name.to_s)
|
|
167
|
-
raise Error, "#{path}: unknown provider #{name.inspect} under direct:; known: #{direct_providers.join(", ")}"
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
Array(entries).flat_map { expand("#{name}/#{it.fetch("model")}", it, overrides, provider: name.to_sym) }
|
|
177
|
+
builder = ContenderBuilder.new(defaults:, overrides:)
|
|
178
|
+
listing.entries.flat_map do |entry|
|
|
179
|
+
builder.call(entry.id, entry.settings, provider: entry.provider.direct? ? entry.provider.name.to_sym : nil)
|
|
171
180
|
end
|
|
172
|
-
vendors + direct
|
|
173
181
|
end
|
|
174
182
|
|
|
183
|
+
# The file's `rate_limit`s, ready for calls. The same one every time, since
|
|
184
|
+
# the limits only hold if every model of a run shares them.
|
|
185
|
+
#
|
|
186
|
+
# @return [Models::Throttle]
|
|
187
|
+
# @raise [Error] on a limit that is missing a number, out of range or set twice
|
|
188
|
+
def throttle = @throttle ||= Models::Throttle.new(listing.limits)
|
|
189
|
+
|
|
175
190
|
# The models given on the command line, at the file's default settings.
|
|
176
191
|
#
|
|
177
192
|
# @param models [Array<String>] model ids
|
|
178
|
-
# @param overrides [Hash{String => Object}]
|
|
179
|
-
# @return [Array<Contender>]
|
|
193
|
+
# @param overrides [Hash{String => Object}] values from the command line; nil entries are ignored
|
|
194
|
+
# @return [Array<Models::Contender>]
|
|
180
195
|
def contenders_for(models, overrides = {})
|
|
181
|
-
|
|
196
|
+
builder = ContenderBuilder.new(defaults:, overrides:)
|
|
197
|
+
models.flat_map { builder.call(it) }
|
|
182
198
|
end
|
|
183
199
|
|
|
184
200
|
private
|
|
185
201
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
temperature: temperature,
|
|
192
|
-
max_tokens: Integer(settings["max_tokens"]),
|
|
193
|
-
provider: provider)
|
|
194
|
-
end
|
|
195
|
-
end
|
|
202
|
+
# The provider sections, read once on first use, so a bad file fails only the commands that read them.
|
|
203
|
+
#
|
|
204
|
+
# @return [Listing]
|
|
205
|
+
# @raise [Error] on a section, entry or limit the file format does not have
|
|
206
|
+
def listing = @listing ||= Listing.new(@data, path:, providers:)
|
|
196
207
|
end
|
|
197
208
|
end
|
|
198
209
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Datasets
|
|
6
|
+
# One eval case, as {Discovery} found it on disk: the text the model is
|
|
7
|
+
# given, the answer a perfect reply would produce, and where it came from.
|
|
8
|
+
#
|
|
9
|
+
# @!attribute [r] id
|
|
10
|
+
# @return [String] unique within its dataset, e.g. "clear/003"
|
|
11
|
+
# @!attribute [r] dataset
|
|
12
|
+
# @return [String] the folder under datasets/, e.g. "colors"
|
|
13
|
+
# @!attribute [r] variant
|
|
14
|
+
# @return [String] the folder under cases/, e.g. "clear" or "vague"
|
|
15
|
+
# @!attribute [r] input
|
|
16
|
+
# @return [String] input.txt, stripped
|
|
17
|
+
# @!attribute [r] expected
|
|
18
|
+
# @return [Object, nil] expected.json parsed; nil when missing or unparsable
|
|
19
|
+
# @!attribute [r] path
|
|
20
|
+
# @return [String] the case's directory
|
|
21
|
+
# @!attribute [r] metadata
|
|
22
|
+
# @return [Hash{Symbol => Object}] number, difficulty, and any `expected_error`
|
|
23
|
+
Case = Data.define(:id, :dataset, :variant, :input, :expected, :path, :metadata) do
|
|
24
|
+
# Absent for a case that sits directly under its variant's folder.
|
|
25
|
+
#
|
|
26
|
+
# @return [Integer, nil] the NNN/ level as a number, when the case has one
|
|
27
|
+
def number = metadata[:number]
|
|
28
|
+
|
|
29
|
+
# A metadata.json may override this, so it need not equal {#number}.
|
|
30
|
+
#
|
|
31
|
+
# @return [Integer] the number, else 0; what the tables sort and show difficulty by
|
|
32
|
+
def difficulty = metadata.fetch(:difficulty, number || 0)
|
|
33
|
+
|
|
34
|
+
# What reports group by, since the same id can recur in another dataset.
|
|
35
|
+
#
|
|
36
|
+
# @return [String] "dataset/id", unique across the suite
|
|
37
|
+
def key = "#{dataset}/#{id}"
|
|
38
|
+
|
|
39
|
+
# A false here is what {Actions::Verify} reports through `metadata[:expected_error]`.
|
|
40
|
+
#
|
|
41
|
+
# @return [Boolean] whether expected.json was there and parsed
|
|
42
|
+
def expected? = !expected.nil?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Datasets
|
|
6
|
+
# Finds the cases under a root with a glob and decodes each path into a
|
|
7
|
+
# {Case}. The default layout is
|
|
8
|
+
#
|
|
9
|
+
# datasets/<dataset>/cases/<variant>/[NNN/]input.txt
|
|
10
|
+
# datasets/<dataset>/cases/<variant>/[NNN/]expected.json
|
|
11
|
+
#
|
|
12
|
+
# The folder after `datasets/` is the dataset, the folder holding the
|
|
13
|
+
# input is the variant, unless it is a number, in which case it is the
|
|
14
|
+
# case number and its parent is the variant.
|
|
15
|
+
class Discovery
|
|
16
|
+
# File holding a case's expected answer, beside its input.
|
|
17
|
+
EXPECTED = "expected.json"
|
|
18
|
+
# Optional file of extra case metadata, beside its input.
|
|
19
|
+
METADATA = "metadata.json"
|
|
20
|
+
|
|
21
|
+
# Where to look: `root` is absolute and `glob` is relative to it.
|
|
22
|
+
#
|
|
23
|
+
# @return [String]
|
|
24
|
+
attr_reader :root, :glob
|
|
25
|
+
|
|
26
|
+
# Reads nothing yet; {#all} does that, once.
|
|
27
|
+
#
|
|
28
|
+
# @param root [String] an absolute directory
|
|
29
|
+
# @param glob [String] relative to root
|
|
30
|
+
def initialize(root:, glob: Runtime::Configuration::DEFAULT_GLOB)
|
|
31
|
+
@root = root
|
|
32
|
+
@glob = glob
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Derived from the cases found, so a folder with no input file does not count.
|
|
36
|
+
#
|
|
37
|
+
# @return [Array<String>] every dataset that has at least one case, sorted
|
|
38
|
+
def datasets = all.map(&:dataset).uniq.sort
|
|
39
|
+
|
|
40
|
+
# {#cases}, for a caller that cannot go on without any.
|
|
41
|
+
#
|
|
42
|
+
# @param dataset [String]
|
|
43
|
+
# @param only [Array<String>, nil] case id prefixes
|
|
44
|
+
# @return [Array<Case>]
|
|
45
|
+
# @raise [Error] when nothing matches
|
|
46
|
+
def cases!(dataset:, only: [])
|
|
47
|
+
cases(dataset:, only: Array(only)).tap do |found|
|
|
48
|
+
raise Error, "no cases match #{Array(only).join(", ")} in #{dataset}" if found.empty?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A filtered view of {#all}. Entries of `only` may themselves be comma-separated,
|
|
53
|
+
# and each matches the start of a case's id or its "dataset/id" key.
|
|
54
|
+
#
|
|
55
|
+
# @param dataset [String, nil] one dataset; nil for all of them
|
|
56
|
+
# @param only [Array<String>] id prefixes to keep ("clear", "clear/00"); empty keeps all
|
|
57
|
+
# @return [Array<Case>] sorted by dataset, variant and number
|
|
58
|
+
# @raise [Error] when the dataset does not exist
|
|
59
|
+
# @example
|
|
60
|
+
# discovery.cases(dataset: "colors", only: ["clear/00"]).map(&:key)
|
|
61
|
+
# # => ["colors/clear/001", "colors/clear/002"]
|
|
62
|
+
def cases(dataset: nil, only: [])
|
|
63
|
+
found = all
|
|
64
|
+
if dataset
|
|
65
|
+
raise Error, "no dataset #{dataset.inspect}; have #{datasets.join(", ")}" unless datasets.include?(dataset)
|
|
66
|
+
|
|
67
|
+
found = found.select { it.dataset == dataset }
|
|
68
|
+
end
|
|
69
|
+
only = only.flat_map { it.split(",") }.map(&:strip).reject(&:empty?)
|
|
70
|
+
found = found.select { |kase| only.any? { kase.id.start_with?(it) || kase.key.start_with?(it) } } unless only.empty?
|
|
71
|
+
found
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Every case under the root, read on the first call and kept for this instance's life.
|
|
75
|
+
#
|
|
76
|
+
# @return [Array<Case>]
|
|
77
|
+
# @raise [JSON::ParserError] when a case's metadata.json is malformed
|
|
78
|
+
def all
|
|
79
|
+
@all ||= Dir[File.join(root, glob)].map { decode(it) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Builds the case at one input file.
|
|
83
|
+
#
|
|
84
|
+
# @param input_path [String]
|
|
85
|
+
# @return [Case]
|
|
86
|
+
# @raise [JSON::ParserError] when the case's metadata.json is malformed
|
|
87
|
+
def decode(input_path)
|
|
88
|
+
dir = File.dirname(input_path)
|
|
89
|
+
parts = Pathname(dir).relative_path_from(Pathname(root)).each_filename.to_a
|
|
90
|
+
number = parts.last.match?(/\A\d+\z/) ? parts.last : nil
|
|
91
|
+
variant = number ? parts[-2] : parts.last
|
|
92
|
+
expected, problem = read_expected(File.join(dir, EXPECTED))
|
|
93
|
+
Case.new(
|
|
94
|
+
id: [variant, number].compact.join("/"),
|
|
95
|
+
dataset: dataset_of(parts),
|
|
96
|
+
variant: variant,
|
|
97
|
+
input: File.read(input_path).strip,
|
|
98
|
+
expected: expected,
|
|
99
|
+
path: dir,
|
|
100
|
+
metadata: metadata(dir, number, problem)
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# The folder after `datasets/`, else the first folder, so a glob that
|
|
107
|
+
# does not say `datasets` still groups by its top level.
|
|
108
|
+
#
|
|
109
|
+
# @param parts [Array<String>] the case directory's path segments, relative to root
|
|
110
|
+
# @return [String] the dataset's name
|
|
111
|
+
def dataset_of(parts)
|
|
112
|
+
index = parts.index("datasets")
|
|
113
|
+
index && parts[index + 1] ? parts[index + 1] : parts.first
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Reports a missing or malformed file as a problem instead of raising, so the
|
|
117
|
+
# case still loads and {Actions::Verify} can name what is wrong with it.
|
|
118
|
+
#
|
|
119
|
+
# @param path [String] where the case's expected.json should be
|
|
120
|
+
# @return [Array(Object, nil), Array(nil, String)] the parsed answer and nil, or nil and the problem
|
|
121
|
+
def read_expected(path)
|
|
122
|
+
return [nil, "#{EXPECTED} is missing"] unless File.exist?(path)
|
|
123
|
+
|
|
124
|
+
[JSON.parse(File.read(path)), nil]
|
|
125
|
+
rescue JSON::ParserError => e
|
|
126
|
+
[nil, "#{EXPECTED} is not valid JSON: #{e.message.lines.first&.strip}"]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Derives the number and difficulty, then lets metadata.json override them.
|
|
130
|
+
#
|
|
131
|
+
# @param dir [String] the case's directory
|
|
132
|
+
# @param number [String, nil] the NNN/ folder name, when the case has one
|
|
133
|
+
# @param problem [String, nil] what is wrong with expected.json, kept as `expected_error`
|
|
134
|
+
# @return [Hash{Symbol => Object}]
|
|
135
|
+
# @raise [JSON::ParserError] when metadata.json is malformed; unlike expected.json, not caught here
|
|
136
|
+
def metadata(dir, number, problem)
|
|
137
|
+
extra = File.exist?(File.join(dir, METADATA)) ? JSON.parse(File.read(File.join(dir, METADATA))).transform_keys(&:to_sym) : {}
|
|
138
|
+
{ number: number&.to_i, difficulty: number.to_i, expected_error: problem }.compact.merge(extra)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
data/lib/llm/evals/launcher.rb
CHANGED
|
@@ -5,6 +5,16 @@ module LLM
|
|
|
5
5
|
# Starts the CLI. The executable and the specs both build one, each with
|
|
6
6
|
# its own argv and streams, and it exits only through kernel.exit.
|
|
7
7
|
class Launcher
|
|
8
|
+
# Commands whose output is piped or which say nothing about a suite.
|
|
9
|
+
UNANNOUNCED = %w[version -V --version completion].freeze
|
|
10
|
+
# Help prints the box after itself, and everything else before.
|
|
11
|
+
HELP = %w[help -h --help].freeze
|
|
12
|
+
|
|
13
|
+
# @param argv [Array<String>] the arguments, which may hold `--helper` and `--no-search` for {Runtime::Helper}
|
|
14
|
+
# @param stdin [IO] nothing reads it yet
|
|
15
|
+
# @param stdout [IO]
|
|
16
|
+
# @param stderr [IO]
|
|
17
|
+
# @param kernel [#exit] replaced in the specs, so a test run survives the exit
|
|
8
18
|
def initialize(argv, stdin = $stdin, stdout = $stdout, stderr = $stderr, kernel = Kernel)
|
|
9
19
|
@argv = argv
|
|
10
20
|
@stdin = stdin
|
|
@@ -13,6 +23,9 @@ module LLM
|
|
|
13
23
|
@kernel = kernel
|
|
14
24
|
end
|
|
15
25
|
|
|
26
|
+
# Runs the command, and exits with its status.
|
|
27
|
+
#
|
|
28
|
+
# @return [void]
|
|
16
29
|
def execute!
|
|
17
30
|
@kernel.exit(run)
|
|
18
31
|
end
|
|
@@ -20,10 +33,18 @@ module LLM
|
|
|
20
33
|
private
|
|
21
34
|
|
|
22
35
|
# dry-cli exits by itself after help or a usage error; its status is kept.
|
|
36
|
+
# @return [Integer] the exit status: the command's own, or 1 on an error, which prints
|
|
37
|
+
# as one line unless -v asked for its backtrace
|
|
23
38
|
def run
|
|
24
|
-
argv, helper = Helper.extract(@argv)
|
|
25
|
-
Helper.
|
|
26
|
-
|
|
39
|
+
argv, helper = Runtime::Helper.extract(@argv)
|
|
40
|
+
argv, search = Runtime::Helper.extract_search(argv)
|
|
41
|
+
search &&= !Runtime::Helper.config_flag?(argv)
|
|
42
|
+
found = Runtime::Helper.locate(helper, search: search)
|
|
43
|
+
LLM::Evals.configuration.located_from(Dir.pwd, search: search)
|
|
44
|
+
in_home(found) do
|
|
45
|
+
Runtime::Helper.load!(found)
|
|
46
|
+
dispatch(argv)
|
|
47
|
+
end
|
|
27
48
|
0
|
|
28
49
|
rescue SystemExit => e
|
|
29
50
|
e.status
|
|
@@ -32,6 +53,39 @@ module LLM
|
|
|
32
53
|
@stderr.puts(e.backtrace) if @argv.intersect?(%w[-v --verbose])
|
|
33
54
|
1
|
|
34
55
|
end
|
|
56
|
+
|
|
57
|
+
# Runs where the search found the suite; the caller's directory comes back afterwards.
|
|
58
|
+
#
|
|
59
|
+
# @param found [Runtime::Helper::Found]
|
|
60
|
+
# @yield the work to run there
|
|
61
|
+
# @return [Object] what the block returned
|
|
62
|
+
def in_home(found, &)
|
|
63
|
+
found.home ? Dir.chdir(found.home, &) : yield
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Runs the command with the configuration's box first, or after the help.
|
|
67
|
+
#
|
|
68
|
+
# @param argv [Array<String>] the arguments dry-cli will read
|
|
69
|
+
# @return [void]
|
|
70
|
+
def dispatch(argv)
|
|
71
|
+
help = argv.empty? || argv.intersect?(HELP)
|
|
72
|
+
announce(argv) unless help
|
|
73
|
+
Dry::CLI.new(CLI).call(arguments: argv, out: @stdout, err: @stderr)
|
|
74
|
+
ensure
|
|
75
|
+
announce(argv) if help
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Says on stderr which configuration the run uses, or that there is none.
|
|
79
|
+
#
|
|
80
|
+
# @param argv [Array<String>] the arguments dry-cli will read
|
|
81
|
+
# @return [void]
|
|
82
|
+
def announce(argv)
|
|
83
|
+
return if UNANNOUNCED.include?(argv.first) || Runtime::Helper.config_flag?(argv)
|
|
84
|
+
|
|
85
|
+
@stdout.flush
|
|
86
|
+
color = @stderr.tty? && !ENV.key?("NO_COLOR")
|
|
87
|
+
@stderr.print("\n", CLI.dataset_conf_label(configuration: LLM::Evals.configuration, color: color))
|
|
88
|
+
end
|
|
35
89
|
end
|
|
36
90
|
end
|
|
37
91
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Models
|
|
6
|
+
# One model at one setting: what a single experiment runs. Built by
|
|
7
|
+
# {Config} from the lineup file, or from `-m` on the command line.
|
|
8
|
+
#
|
|
9
|
+
# `provider` is nil for an entry whose id picks its provider, as OpenRouter's
|
|
10
|
+
# do, and the provider's name for a direct provider's entry. It travels with the
|
|
11
|
+
# contender rather than being read back out of the id, so `typesafe/jev-router`
|
|
12
|
+
# (Jev on OpenRouter) and a direct `typesafe/jev-latest` can never be confused.
|
|
13
|
+
#
|
|
14
|
+
# @!attribute [r] model
|
|
15
|
+
# @return [String] the model id, as the lineup names it
|
|
16
|
+
# @!attribute [r] effort
|
|
17
|
+
# @return [String, nil] reasoning effort; "default" or nil sends none
|
|
18
|
+
# @!attribute [r] temperature
|
|
19
|
+
# @return [Numeric, String, nil] sampling temperature; "default" or nil sends none
|
|
20
|
+
# @!attribute [r] max_tokens
|
|
21
|
+
# @return [Integer, nil] output token cap; nil sends none
|
|
22
|
+
# @!attribute [r] provider
|
|
23
|
+
# @return [Symbol, nil] a direct provider's name; nil when the id picks the provider
|
|
24
|
+
Contender = Data.define(:model, :effort, :temperature, :max_tokens, :provider) do
|
|
25
|
+
# Gives everything but the model a default, so `-m` needs to name only that.
|
|
26
|
+
#
|
|
27
|
+
# @param model [String] the model id, as the lineup names it
|
|
28
|
+
# @param effort [String, nil] reasoning effort
|
|
29
|
+
# @param temperature [Numeric, String, nil] sampling temperature
|
|
30
|
+
# @param max_tokens [Integer, nil] output token cap
|
|
31
|
+
# @param provider [Symbol, nil] a direct provider's name
|
|
32
|
+
def initialize(model:, effort: "default", temperature: "default", max_tokens: nil, provider: nil) = super
|
|
33
|
+
|
|
34
|
+
# @return [Boolean] whether the entry came from a direct provider's section
|
|
35
|
+
def direct? = !provider.nil?
|
|
36
|
+
|
|
37
|
+
# @return [String] unique across the lineup: "typesafe:typesafe/jev-latest" for a direct entry
|
|
38
|
+
def key = direct? ? "#{provider}:#{model}" : model
|
|
39
|
+
|
|
40
|
+
# The settings as a {Model} takes them: "default" means nil, send nothing.
|
|
41
|
+
#
|
|
42
|
+
# @return [Hash{Symbol => Object}]
|
|
43
|
+
def settings
|
|
44
|
+
{ effort: effort.to_s == "default" || effort.nil? ? nil : effort.to_s,
|
|
45
|
+
temperature: temperature.to_s == "default" || temperature.nil? ? nil : Float(temperature),
|
|
46
|
+
max_tokens: max_tokens.nil? ? nil : Integer(max_tokens) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Models
|
|
6
|
+
# One model at one setting, reached through its provider. Adapters call
|
|
7
|
+
# {#complete}; the settings an experiment is named by are read here.
|
|
8
|
+
class Model
|
|
9
|
+
# The settings every provider understands; a provider may accept more.
|
|
10
|
+
SETTINGS = %i[effort temperature max_tokens].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :id, :provider, :settings
|
|
13
|
+
|
|
14
|
+
# @!attribute [r] id
|
|
15
|
+
# The model id as the lineup names it, with any provider prefix.
|
|
16
|
+
# @return [String]
|
|
17
|
+
|
|
18
|
+
# @!attribute [r] provider
|
|
19
|
+
# What answers the calls.
|
|
20
|
+
# @return [Providers::Base]
|
|
21
|
+
|
|
22
|
+
# @!attribute [r] settings
|
|
23
|
+
# Frozen; effort, temperature and max_tokens, and whatever else a provider accepts.
|
|
24
|
+
# @return [Hash{Symbol => Object}]
|
|
25
|
+
|
|
26
|
+
# @param id [String] the model id as the lineup names it, e.g. "anthropic/claude-haiku-5.5"
|
|
27
|
+
# @param provider [Providers::Base] what answers {#complete}
|
|
28
|
+
# @param settings [Hash{Symbol => Object}] effort, temperature, max_tokens; nil leaves the model's default
|
|
29
|
+
# @param throttle [Throttle] what every call passes through; share one to share its limits
|
|
30
|
+
def initialize(id:, provider:, settings: {}, throttle: Throttle.new)
|
|
31
|
+
@id = id
|
|
32
|
+
@provider = provider
|
|
33
|
+
@settings = settings.transform_keys(&:to_sym).freeze
|
|
34
|
+
@throttle = throttle
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [String, nil] reasoning effort; nil sends none
|
|
38
|
+
def effort = settings[:effort]
|
|
39
|
+
|
|
40
|
+
# @return [Float, nil] sampling temperature; nil sends none
|
|
41
|
+
def temperature = settings[:temperature]
|
|
42
|
+
|
|
43
|
+
# @return [Integer, nil] output token cap
|
|
44
|
+
def max_tokens = settings[:max_tokens]
|
|
45
|
+
|
|
46
|
+
# @return [String] the id without the provider's prefix, as the provider's API knows it
|
|
47
|
+
def name = provider.model_name(id)
|
|
48
|
+
|
|
49
|
+
# @return [String] "provider:id", unique where two providers render the same id
|
|
50
|
+
def key = "#{provider.name}:#{id}"
|
|
51
|
+
|
|
52
|
+
# One call. Transport failures raise; they are the run's errors, not the model's.
|
|
53
|
+
#
|
|
54
|
+
# @param system [String, nil] the system prompt
|
|
55
|
+
# @param user [String] the user prompt
|
|
56
|
+
# @param json [Boolean] ask the provider for a JSON object reply where it supports that
|
|
57
|
+
# @return [Results::ProviderResponse]
|
|
58
|
+
def complete(user:, system: nil, json: true)
|
|
59
|
+
@throttle.call(self) { provider.complete(self, system: system, user: user, json: json) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# One request of typed questions, for a provider of request type `typesafe`.
|
|
63
|
+
#
|
|
64
|
+
# @param state [Object] what the questions are about, as the suite's adapter shaped it
|
|
65
|
+
# @param questions [Hash{String => Hash}] by name: type, instructions, criteria
|
|
66
|
+
# @return [Results::ProviderResponse]
|
|
67
|
+
def ask(state:, questions:)
|
|
68
|
+
@throttle.call(self) { provider.ask(self, state: state, questions: questions) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [String] the effort as reports show it; "default" sends none
|
|
72
|
+
def effort_label = effort || "default"
|
|
73
|
+
|
|
74
|
+
# @return [Float, String] the temperature as reports show it; "default" sends none
|
|
75
|
+
def temperature_label = temperature.nil? ? "default" : temperature
|
|
76
|
+
|
|
77
|
+
# @return [String] e.g. "effort=low t=0.2"
|
|
78
|
+
def label = "effort=#{effort_label} t=#{temperature_label}"
|
|
79
|
+
|
|
80
|
+
# How an experiment's name starts.
|
|
81
|
+
#
|
|
82
|
+
# @return [String] e.g. "anthropic/claude-haiku-5.5 effort=low t=0.2"
|
|
83
|
+
def to_s = "#{id} #{label}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|