llm-evals 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +26 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +764 -0
  5. data/exe/evals +9 -0
  6. data/exe/llm-evals +14 -0
  7. data/lib/llm/evals/adapter.rb +47 -0
  8. data/lib/llm/evals/case.rb +36 -0
  9. data/lib/llm/evals/cli/cases.rb +48 -0
  10. data/lib/llm/evals/cli/command.rb +163 -0
  11. data/lib/llm/evals/cli/compare.rb +40 -0
  12. data/lib/llm/evals/cli/formatting.rb +16 -0
  13. data/lib/llm/evals/cli/leaderboard.rb +183 -0
  14. data/lib/llm/evals/cli/model_colors.rb +23 -0
  15. data/lib/llm/evals/cli/model_command.rb +437 -0
  16. data/lib/llm/evals/cli/progress_bars.rb +147 -0
  17. data/lib/llm/evals/cli/render.rb +37 -0
  18. data/lib/llm/evals/cli/run.rb +69 -0
  19. data/lib/llm/evals/cli/standings.rb +114 -0
  20. data/lib/llm/evals/cli/verify.rb +24 -0
  21. data/lib/llm/evals/cli/version.rb +15 -0
  22. data/lib/llm/evals/cli.rb +43 -0
  23. data/lib/llm/evals/completion.rb +11 -0
  24. data/lib/llm/evals/config.rb +198 -0
  25. data/lib/llm/evals/configuration.rb +173 -0
  26. data/lib/llm/evals/contender.rb +31 -0
  27. data/lib/llm/evals/discovery.rb +95 -0
  28. data/lib/llm/evals/experiment.rb +158 -0
  29. data/lib/llm/evals/helper.rb +98 -0
  30. data/lib/llm/evals/launcher.rb +37 -0
  31. data/lib/llm/evals/metric.rb +26 -0
  32. data/lib/llm/evals/model.rb +55 -0
  33. data/lib/llm/evals/providers/base.rb +93 -0
  34. data/lib/llm/evals/providers/catalog.rb +32 -0
  35. data/lib/llm/evals/providers/open_router.rb +64 -0
  36. data/lib/llm/evals/providers/registry.rb +112 -0
  37. data/lib/llm/evals/providers/type_safe.rb +53 -0
  38. data/lib/llm/evals/render/reports/html.rb +266 -0
  39. data/lib/llm/evals/render/reports/json.rb +172 -0
  40. data/lib/llm/evals/render/reports/pipeline.rb +153 -0
  41. data/lib/llm/evals/render/reports/terminal.rb +46 -0
  42. data/lib/llm/evals/reply.rb +18 -0
  43. data/lib/llm/evals/run_result.rb +14 -0
  44. data/lib/llm/evals/runtime.rb +74 -0
  45. data/lib/llm/evals/schema_validator.rb +84 -0
  46. data/lib/llm/evals/score.rb +26 -0
  47. data/lib/llm/evals/scorer.rb +45 -0
  48. data/lib/llm/evals/templates/html_report.css +206 -0
  49. data/lib/llm/evals/templates/html_report.html.erb +184 -0
  50. data/lib/llm/evals/templates/html_report.js +48 -0
  51. data/lib/llm/evals/testing/echo_adapter.rb +29 -0
  52. data/lib/llm/evals/testing/fake_model.rb +18 -0
  53. data/lib/llm/evals/testing/fake_provider.rb +67 -0
  54. data/lib/llm/evals/tracker.rb +80 -0
  55. data/lib/llm/evals/trackers/braintrust.rb +91 -0
  56. data/lib/llm/evals/trackers/local.rb +78 -0
  57. data/lib/llm/evals/trackers/logfire.rb +143 -0
  58. data/lib/llm/evals/trackers/registry.rb +82 -0
  59. data/lib/llm/evals/usage.rb +33 -0
  60. data/lib/llm/evals/verify.rb +49 -0
  61. data/lib/llm/evals/version.rb +7 -0
  62. data/lib/llm/evals/warnings.rb +98 -0
  63. data/lib/llm/evals/weighted_score.rb +116 -0
  64. data/lib/llm/evals.rb +83 -0
  65. metadata +274 -0
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module CLI
6
+ # One experiment per model and dataset, each with a per-case breakdown.
7
+ # Without -m, every model .llm-evals.config.yml lists. {Compare} runs the same
8
+ # experiments and prints only the leaderboard.
9
+ class Run < ModelCommand
10
+ desc "Run every case against a model, or every model in .llm-evals.config.yml, with a per-case breakdown"
11
+
12
+ example [
13
+ "-m anthropic/claude-haiku-5.5",
14
+ "-m openai/gpt-4.1-mini -t 0 -c clear/00 # one variant's first cases",
15
+ "-m google/gemini-2.5-flash -e high --local # score without Braintrust",
16
+ "-d colors,animals -o # both datasets; the breakdowns go to log/",
17
+ "-n # show what would run, call nothing"
18
+ ]
19
+
20
+ def call(models:, dataset:, dry_run:, **options)
21
+ config = lineup(options)
22
+ weigh(config)
23
+ contenders = contenders(config, models, options)
24
+ datasets = datasets(dataset)
25
+ return matrix(datasets, contenders) if dry_run
26
+
27
+ setup!(contenders, options)
28
+ runs = datasets.flat_map { |name| contenders.map { experiment(it, config, options, dataset: name) } }
29
+ summaries = execute(runs, concurrency: concurrency(options))
30
+ report(summaries,
31
+ output: options[:output],
32
+ open: options[:open],
33
+ scores: options[:scores],
34
+ format: options[:format],
35
+ config:,
36
+ options:,
37
+ run_options: serialize_run_options(config:, contenders:, datasets:, runs:, models:, dataset:, options:)) do |display_summaries|
38
+ display_summaries.each { breakdown(it, titled: display_summaries.size > 1) }
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ # One row per case: its score, and whatever the scorer diagnosed.
45
+ def breakdown(summary, titled: false)
46
+ status_box :info, summary.name if titled
47
+ rows = summary.results.map do |result|
48
+ [result.case.id, result.case.difficulty, result.score ? percent(result.score.value) : "-", diagnosis(result)]
49
+ end
50
+ table(rows, header: %w[case difficulty score diagnostics], fill: 3, wrap: true)
51
+ end
52
+
53
+ # The scorer's diagnostics, one per line, rendered without knowing
54
+ # their shape: "field: value" for a Hash, each item for an Array.
55
+ def diagnosis(result)
56
+ return result.error if result.failed?
57
+
58
+ result.score.diagnostics.map do |field, detail|
59
+ case detail
60
+ when Hash then "#{field}: #{detail.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}"
61
+ when Array then "#{field}: #{detail.join(", ")}"
62
+ else "#{field}: #{detail}"
63
+ end
64
+ end.join("\n")
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module CLI
6
+ # Who ranks where: the arithmetic behind {Leaderboard}, which prints nothing.
7
+ class Standings
8
+ # How close to the best primary metric a model must come to compete on
9
+ # price: 0.02 is two percentage points.
10
+ VALUE_MARGIN = 0.02
11
+
12
+ attr_reader :metrics, :primary, :weighted
13
+
14
+ # @param metrics [Array<Metric>]
15
+ # @param primary [Metric] the metric the ranking reads
16
+ # @param weighted [WeightedScore, nil] nil when the file sets no weights
17
+ def initialize(metrics:, primary:, weighted: nil)
18
+ @metrics = metrics
19
+ @primary = primary
20
+ @weighted = weighted
21
+ end
22
+
23
+ # The best value: the cheapest entry within {VALUE_MARGIN} of the best
24
+ # primary metric. Entries with errors, or no score, do not compete. A
25
+ # plain score-per-dollar ratio would crown whatever is nearly free,
26
+ # however wrong it is.
27
+ #
28
+ # @param entries [Array<Array(Float, Float, Integer)>] primary metric, cost, errors
29
+ # @return [Integer, nil] the winner's index
30
+ def best_value(entries)
31
+ eligible = entries.each_index.select { entries[it][0] && entries[it][2].zero? }
32
+ return nil if eligible.empty?
33
+
34
+ floor = eligible.map { entries[it][0] }.max - VALUE_MARGIN
35
+ eligible.select { entries[it][0] >= floor - 1e-9 }.min_by { entries[it][1] }
36
+ end
37
+
38
+ # Each contender's numbers over the given experiments, best first.
39
+ #
40
+ # @param summaries [Array<Experiment::Summary>]
41
+ # @return [Array<Hash>]
42
+ def contender_groups(summaries)
43
+ groups = summaries.group_by { [it.model.key, it.model.effort, it.model.temperature] }.values.map do |group|
44
+ values = group.filter_map { it.mean(primary) }
45
+ { first: group.first, errors: group.sum { it.errors.size },
46
+ value: values.size == group.size ? values.sum / values.size : nil,
47
+ usage: group.map(&:usage).reduce(Usage.zero, :+),
48
+ row: overall_row(group) }
49
+ end
50
+ scores = weighted_by(groups.map { it[:row] })
51
+ groups.each_with_index { |g, index| g[:weighted] = scores[index] }
52
+ groups.sort_by { -((weighted ? it[:weighted] : it[:value]) || -1) }
53
+ end
54
+
55
+ # @param groups [Array<Hash>] from {#contender_groups}
56
+ # @return [Integer, nil] the index of the best value
57
+ def best_contender(groups) = best_value(groups.map { [it[:value], it[:usage].cost_usd, it[:errors]] })
58
+
59
+ # @param summaries [Array<Experiment::Summary>]
60
+ # @return [Hash{Experiment::Summary => Float, nil}] by identity, each compared within its dataset
61
+ def weighted_scores(summaries)
62
+ summaries.group_by(&:dataset).values.each_with_object({}.compare_by_identity) do |group, scores|
63
+ group.zip(weighted_by(group.map { weighted_row(it) })).each { |summary, value| scores[summary] = value }
64
+ end
65
+ end
66
+
67
+ # The best value in each dataset's block of the leaderboard.
68
+ #
69
+ # @param ranked [Array<Experiment::Summary>]
70
+ # @return [Array<Integer>] indices into ranked
71
+ def best_per_dataset(ranked)
72
+ ranked.each_index.group_by { ranked[it].dataset }.values.filter_map do |indices|
73
+ winner = best_value(indices.map { [ranked[it].mean(primary), ranked[it].usage.cost_usd, ranked[it].errors.size] })
74
+ winner && indices[winner]
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ # What {WeightedScore} reads from one experiment.
81
+ #
82
+ # @param summary [Experiment::Summary]
83
+ # @return [Hash{String => Numeric, nil}]
84
+ def weighted_row(summary)
85
+ metrics.to_h { [it.name, summary.mean(it)] }.merge(
86
+ "score" => summary.score,
87
+ "errors" => summary.errors.size,
88
+ "time" => summary.duration,
89
+ "cost" => summary.usage.cost_usd.to_f
90
+ )
91
+ end
92
+
93
+ # One contender over every dataset: errors and magnitudes add up, the rest average.
94
+ #
95
+ # @param group [Array<Experiment::Summary>]
96
+ # @return [Hash{String => Numeric, nil}]
97
+ def overall_row(group)
98
+ rows = group.map { weighted_row(it) }
99
+ rows.first.keys.to_h do |name|
100
+ values = rows.map { it[name] }
101
+ total = values.sum(&:to_f)
102
+ next [name, nil] if values.any?(&:nil?)
103
+
104
+ [name, ["errors", *WeightedScore::MAGNITUDES].include?(name) ? total : total / values.size]
105
+ end
106
+ end
107
+
108
+ # @param rows [Array<Hash>]
109
+ # @return [Array<Float, nil>] nils when the file sets no weights
110
+ def weighted_by(rows) = weighted ? weighted.call(rows) : Array.new(rows.size)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module CLI
6
+ # Offline checks that every case agrees with what its suite demands of it.
7
+ class Verify < Command
8
+ desc "Check every case's input and expected answer, through the adapter and the scorer (no API calls)"
9
+
10
+ def call(**)
11
+ all = discovery.all
12
+ failures = LLM::Evals::Verify.new(configuration).problems
13
+ if failures.empty?
14
+ status_box :success, "#{all.size} cases verified across #{discovery.datasets.join(", ")}"
15
+ return
16
+ end
17
+
18
+ failures.each { |name, problems| problems.each { status_box :error, "#{name}: #{it}", stream: err } }
19
+ raise Error, "#{failures.size} case(s) failed verification"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module CLI
6
+ class Version < Command
7
+ desc "Print the version"
8
+
9
+ def call(**)
10
+ out.puts LLM::Evals::VERSION
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # Every command the executable answers to. The help's title and
6
+ # description come from the suite's configuration, which the executable
7
+ # loads before this module is first referenced.
8
+ module CLI
9
+ extend Dry::CLI::Registry
10
+
11
+ # What users type, so what the help and shell completion are written for.
12
+ # exe/llm-evals exists only for a system where `evals` is taken.
13
+ PROGRAM = "evals"
14
+
15
+ Dry::CLI::Help.configure do
16
+ configuration = LLM::Evals.configuration
17
+ title configuration.name == Configuration::DEFAULT_NAME ? PROGRAM : "#{PROGRAM} (#{configuration.name})"
18
+ types = configuration.providers.uniq(&:request_type).map { "#{it.request_type} (#{it.request_type_description})" }
19
+ description "#{configuration.description} Request types: #{types.join("; ")}."
20
+
21
+ columns = IO.console&.winsize&.last || 80
22
+ width columns.clamp(60, 120)
23
+ exit_code_without_arguments 0
24
+
25
+ styles do
26
+ heading :bold, :cyan, case: :UPPERCASE
27
+ usage :yellow
28
+ example :yellow
29
+ option :green
30
+ example_comment :bright_black
31
+ end
32
+ end
33
+
34
+ register "run", Run
35
+ register "compare", Compare
36
+ register "render", Render
37
+ register "cases", Cases
38
+ register "verify", Verify
39
+ register "version", Version, aliases: ["-V", "--version"]
40
+ register "completion", Dry::CLI::Autocomplete::Command[self, program_name: PROGRAM]
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # What a {Model} returns from one call: the text, what it cost, and the
6
+ # provider's raw response for an adapter that needs more than the text.
7
+ Completion = Data.define(:text, :usage, :raw) do
8
+ def initialize(text:, usage: Usage.zero, raw: nil) = super(text: text.to_s, usage: Usage.from(usage), raw: raw)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module LLM
6
+ module Evals
7
+ # The suite's `.llm-evals.config.yml`: which models to compare, at which
8
+ # settings, and which kinds of request the suite can produce.
9
+ #
10
+ # project: my-evals # Braintrust project name
11
+ # request_types: [prompt] # required: what the adapter sends; a provider must match
12
+ # tracker: braintrust # optional: the one remote tracker; else the environment decides
13
+ # logfire: # optional: a tracker's own settings
14
+ # include_replies: true
15
+ # require: my_gem/evals # optional: a path (or list) to require, for a suite with no eval_helper.rb
16
+ # adapter: MyGem::Evals::Adapter # optional: constant names, instantiated with no arguments
17
+ # scorer: MyGem::Evals::Scorer
18
+ # defaults:
19
+ # effort: default
20
+ # temperature: 0.2
21
+ # max_tokens: 4096
22
+ # parallelism: 4
23
+ # results:
24
+ # autosave: true # completed runs are saved as JSON (default)
25
+ # destination: ./evals/results/
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}
29
+ # weights:
30
+ # score: 0.6
31
+ # 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
39
+ #
40
+ # Each entry becomes "<vendor>/<model>", the id a provider recognises. A
41
+ # key under `direct:` names a provider; its entries carry that provider.
42
+ class Config
43
+ # The request types a suite may declare. `prompt` is a chat completion:
44
+ # system and user text in, text out. `typesafe` is TypeSafe's typed
45
+ # question API, whose request shape the first such suite will define.
46
+ REQUEST_TYPES = %w[prompt typesafe].freeze
47
+
48
+ BUILTIN = {
49
+ "effort" => "default", "temperature" => 0.2, "max_tokens" => 4096, "parallelism" => 4
50
+ }.freeze
51
+
52
+ Results = Data.define(:autosave, :destination, :temp_dir, :display_mode)
53
+ RESULT_DISPLAYS = %w[none tui browser].freeze
54
+
55
+ attr_reader :path, :direct_providers
56
+
57
+ # @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)
60
+ @path = path
61
+ @direct_providers = direct_providers
62
+ @data = File.exist?(path) ? (YAML.safe_load_file(path) || {}) : {}
63
+ end
64
+
65
+ # @return [String, nil] the Braintrust project name
66
+ def project = @data["project"]
67
+
68
+ # The remote tracker the file chooses; a list of more than one is an
69
+ # error, since exactly one remote tracker runs, or none.
70
+ #
71
+ # @return [String, nil]
72
+ # @raise [Error] on a list of several
73
+ def tracker
74
+ listed = Array(@data["tracker"]).map(&:to_s)
75
+ raise Error, "#{path}: tracker: names #{listed.join(", ")}; exactly one remote tracker runs, or none" if listed.size > 1
76
+
77
+ listed.first
78
+ end
79
+
80
+ # A tracker's own section of the file, e.g. `logfire: { include_replies: true }`.
81
+ #
82
+ # @param name [String]
83
+ # @return [Hash]
84
+ def tracker_options(name) = (@data[name.to_s].is_a?(Hash) ? @data[name.to_s] : {}).transform_keys(&:to_s)
85
+
86
+ # @return [Array<String>] the `require:` key, one path or a list
87
+ def requires = Array(@data["require"]).map(&:to_s)
88
+
89
+ # @return [String, nil] the `adapter:` constant name
90
+ def adapter_name = @data["adapter"]&.to_s
91
+
92
+ # @return [String, nil] the `scorer:` constant name
93
+ def scorer_name = @data["scorer"]&.to_s
94
+
95
+ # The kinds of request the suite's adapter produces. A provider whose
96
+ # request type is not listed cannot serve this suite, whatever model it
97
+ # names, so the list is required: without it every contender would be
98
+ # admitted on faith and fail at the first call.
99
+ #
100
+ # @return [Array<Symbol>]
101
+ # @raise [Error] when the key is missing, empty, or names an unknown type
102
+ def request_types
103
+ listed = Array(@data["request_types"]).map(&:to_s)
104
+ raise Error, "#{path}: request_types is required; list one or more of #{REQUEST_TYPES.join(", ")}" if listed.empty?
105
+
106
+ unknown = listed - REQUEST_TYPES
107
+ raise Error, "#{path}: unknown request_types #{unknown.join(", ")}; known: #{REQUEST_TYPES.join(", ")}" if unknown.any?
108
+
109
+ listed.map(&:to_sym)
110
+ end
111
+
112
+ # @return [Hash{String => Object}] the built-in defaults under the file's own
113
+ def defaults = BUILTIN.merge(@data.fetch("defaults", nil) || {})
114
+
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
+ # @return [Results]
120
+ # @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
134
+
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}
144
+ # @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)
151
+
152
+ weights.transform_keys(&:to_s)
153
+ end
154
+
155
+ # Every vendor/model/effort/temperature combination the file lists,
156
+ # `vendors:` first, then `direct:`.
157
+ #
158
+ # @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
161
+ 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) }
171
+ end
172
+ vendors + direct
173
+ end
174
+
175
+ # The models given on the command line, at the file's default settings.
176
+ #
177
+ # @param models [Array<String>] model ids
178
+ # @param overrides [Hash{String => Object}]
179
+ # @return [Array<Contender>]
180
+ def contenders_for(models, overrides = {})
181
+ models.flat_map { expand(it, {}, overrides) }
182
+ end
183
+
184
+ private
185
+
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
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # Everything a suite tells the gem, through {LLM::Evals.configure}.
6
+ # Paths are relative to `root`, the directory the eval helper lives in.
7
+ class Configuration
8
+ DEFAULT_GLOB = "datasets/*/cases/**/input.txt"
9
+ DEFAULT_NAME = "llm-evals"
10
+ DEFAULT_CONFIG = ".llm-evals.config.yml"
11
+
12
+ attr_writer :adapter, :scorer
13
+ attr_reader :name, :root, :env_files, :cases_glob, :hooks, :providers, :trackers, :description
14
+
15
+ def initialize
16
+ @name = DEFAULT_NAME
17
+ @root = Dir.pwd
18
+ @env_files = []
19
+ @cases_glob = DEFAULT_GLOB
20
+ @config_path = DEFAULT_CONFIG
21
+ @hooks = []
22
+ @providers = Providers::Registry.default
23
+ @trackers = Trackers::Registry.default
24
+ @description = "Run a suite of cases against one or more models and score the replies."
25
+ @project = nil
26
+ end
27
+
28
+ # Names the suite. The name titles the help and tags the experiments.
29
+ #
30
+ # @param name [String]
31
+ # @param root [String] where .llm-evals.config.yml and datasets/ live; the helper's directory
32
+ # @param env_files [Array<String>] dotenv files to load, relative to root; the first that exists wins nothing,
33
+ # all that exist load, and an exported variable beats them all
34
+ # @param description [String, nil] one line for the help's header
35
+ # @return [void]
36
+ def suite(name, root:, env_files: [], description: nil)
37
+ @name = name
38
+ @root = File.expand_path(root)
39
+ @env_files = env_files.map { File.expand_path(it, @root) }
40
+ @description = description if description
41
+ end
42
+
43
+ # Where the cases are: a glob relative to root ending in the input file.
44
+ # Each match is a case; the expected answer is `expected.json` beside it.
45
+ #
46
+ # @param glob [String]
47
+ # @return [void]
48
+ def cases(glob)
49
+ @cases_glob = glob
50
+ end
51
+
52
+ # @param object [Adapter]
53
+ # @return [void]
54
+
55
+ # @param object [Adapter, nil] setting; reading when nil
56
+ # @return [Adapter]
57
+ def adapter(object = nil)
58
+ @adapter = object unless object.nil?
59
+ @adapter
60
+ end
61
+
62
+ # @param object [Scorer, nil] setting; reading when nil
63
+ # @return [Scorer]
64
+ def scorer(object = nil)
65
+ @scorer = object unless object.nil?
66
+ @scorer
67
+ end
68
+
69
+ # @param object [Scorer]
70
+ # @return [void]
71
+
72
+ # Runs once, after the env files are loaded and before any model is
73
+ # called. The place to reset a library that read ENV at require time.
74
+ #
75
+ # @yield
76
+ # @return [void]
77
+ def before_run(&block)
78
+ @hooks << block
79
+ end
80
+
81
+ # Adds a provider, ahead of the defaults, so a suite's own transport
82
+ # wins the ids it claims.
83
+ #
84
+ # @param provider [Providers::Base]
85
+ # @return [void]
86
+ def provider(provider)
87
+ @providers.register(provider)
88
+ end
89
+
90
+ # Adds a tracker class, so a run can choose it by name with `--tracker`
91
+ # or `tracker:`, or have its env key select it.
92
+ #
93
+ # @param klass [Class<Tracker>]
94
+ # @return [void]
95
+ def tracker(klass)
96
+ @trackers.register(klass)
97
+ end
98
+
99
+ # @param path [String, nil] setting, relative to root; reading when nil
100
+ # @return [String] the absolute path of the lineup file, `.llm-evals.config.yml` at the root by default
101
+ def config(path = nil)
102
+ @config_path = path unless path.nil?
103
+ File.expand_path(@config_path, root)
104
+ end
105
+
106
+ # @param name [String, nil] setting; reading when nil
107
+ # @return [String, nil] the Braintrust project, when .llm-evals.config.yml does not name one
108
+ def project(name = nil)
109
+ @project = name unless name.nil?
110
+ @project
111
+ end
112
+
113
+ # @return [Discovery] the cases, as the glob finds them
114
+ def discovery = Discovery.new(root: root, glob: cases_glob)
115
+
116
+ # @return [Config] the lineup file
117
+ def lineup = Config.new(config, direct_providers: providers.direct_names)
118
+
119
+ # @return [Runtime]
120
+ def runtime = Runtime.new(env_files: env_files, hooks: hooks, app_name: name)
121
+
122
+ # @return [Boolean] whether an adapter and a scorer are registered
123
+ def complete? = !adapter.nil? && !scorer.nil?
124
+
125
+ # Fails a command that needs the suite when no helper was loaded, with
126
+ # the fix in the message, instead of a NoMethodError on nil.
127
+ #
128
+ # @return [self]
129
+ # @raise [Error]
130
+ def require_suite!
131
+ resolve_from_config!
132
+ return self if complete?
133
+
134
+ raise Error,
135
+ "no suite is configured: register an adapter and a scorer in evals/eval_helper.rb, or set " \
136
+ "adapter: and scorer: (with require:) in #{config}; --helper PATH or LLM_EVALS_HELPER names another helper"
137
+ end
138
+
139
+ # Fills whatever the helper left unset from the config file's `require:`,
140
+ # `adapter:` and `scorer:` keys, so a suite can be config-only: its Ruby
141
+ # lives in its own gem and `evals/` holds the file and the datasets. The
142
+ # helper, when there is one, was loaded first and wins.
143
+ #
144
+ # @return [void]
145
+ # @raise [Error] naming the key and the value when a require fails or a constant is unknown
146
+ def resolve_from_config!
147
+ return if complete? || !File.exist?(config)
148
+
149
+ file = lineup
150
+ file.requires.each do |feature|
151
+ require feature
152
+ rescue LoadError => e
153
+ raise Error, "#{config}: require: #{feature} could not be loaded (#{e.message}); is its gem in the bundle?"
154
+ end
155
+ @adapter ||= instantiate("adapter", file.adapter_name)
156
+ @scorer ||= instantiate("scorer", file.scorer_name) # rubocop:disable Naming/MemoizedInstanceVariableName
157
+ end
158
+
159
+ private
160
+
161
+ # @return [Object, nil] a new instance of the named constant; nil when no name is set
162
+ def instantiate(key, name)
163
+ return nil if name.nil? || name.empty?
164
+
165
+ Object.const_get(name).new
166
+ rescue NameError, ArgumentError => e
167
+ raise Error,
168
+ "#{config}: #{key}: #{name} could not be instantiated (#{e.class}: #{e.message}); " \
169
+ "name a constant the require: key loads, taking no arguments"
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # One model at one setting: what a single experiment runs. Built by
6
+ # {Config} from the lineup file, or from `-m` on the command line.
7
+ #
8
+ # `provider` is nil for a `vendors:` entry, which OpenRouter serves, and
9
+ # the provider's name for a `direct:` entry. It travels with the
10
+ # contender rather than being read back out of the id, so `typesafe/jev-router`
11
+ # (Jev on OpenRouter) and a direct `typesafe/jev-latest` can never be confused.
12
+ Contender = Data.define(:model, :effort, :temperature, :max_tokens, :provider) do
13
+ def initialize(model:, effort: "default", temperature: "default", max_tokens: nil, provider: nil) = super
14
+
15
+ # @return [Boolean] whether the entry came from `direct:`
16
+ def direct? = !provider.nil?
17
+
18
+ # @return [String] unique across the lineup: "typesafe:typesafe/jev-latest" for a direct entry
19
+ def key = direct? ? "#{provider}:#{model}" : model
20
+
21
+ # The settings as a {Model} takes them: "default" means nil, send nothing.
22
+ #
23
+ # @return [Hash{Symbol => Object}]
24
+ def settings
25
+ { effort: effort.to_s == "default" || effort.nil? ? nil : effort.to_s,
26
+ temperature: temperature.to_s == "default" || temperature.nil? ? nil : Float(temperature),
27
+ max_tokens: max_tokens.nil? ? nil : Integer(max_tokens) }
28
+ end
29
+ end
30
+ end
31
+ end