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
@@ -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: browser # none, tui or browser
28
- # weighted_score: # optional: what matters most; see {WeightedScore}
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
- # vendors: # the OpenRouter list; every entry takes a prompt
33
- # anthropic:
34
- # - model: claude-haiku-5.5
35
- # effort: [default, low] # a list expands into one contender per value
36
- # direct: # a vendor's own API, one request type each
37
- # typesafe:
38
- # - model: jev-latest
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
- # Each entry becomes "<vendor>/<model>", the id a provider recognises. A
41
- # key under `direct:` names a provider; its entries carry that provider.
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
- Results = Data.define(:autosave, :destination, :temp_dir, :display_mode)
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
- attr_reader :path, :direct_providers
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 direct_providers [Array<String>] the provider names `direct:` may use
59
- def initialize(path, direct_providers: Providers::Registry.default.direct_names)
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
- @direct_providers = direct_providers
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
- destination = section.fetch("destination", "./evals/results").to_s
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
- weights.transform_keys(&:to_s)
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
- # `vendors:` first, then `direct:`.
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 key under `direct:` that names no provider
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
- vendors = (@data["vendors"] || {}).flat_map do |vendor, entries|
163
- Array(entries).flat_map { expand("#{vendor}/#{it.fetch("model")}", it, overrides) }
164
- end
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
- models.flat_map { expand(it, {}, overrides) }
196
+ builder = ContenderBuilder.new(defaults:, overrides:)
197
+ models.flat_map { builder.call(it) }
182
198
  end
183
199
 
184
200
  private
185
201
 
186
- def expand(model, entry, overrides, provider: nil)
187
- settings = defaults.merge(entry).merge(overrides.compact)
188
- Array(settings["effort"]).product(Array(settings["temperature"])).map do |effort, temperature|
189
- Contender.new(model: model,
190
- effort: effort.to_s,
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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # The cases a suite evaluates, and how they are found on disk.
6
+ module Datasets
7
+ end
8
+ end
9
+ end
@@ -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.load!(Helper.locate(helper))
26
- Dry::CLI.new(CLI).call(arguments: argv, out: @stdout, err: @stderr)
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