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
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module LLM
6
+ module Evals
7
+ module Results
8
+ # Everything a run would otherwise print as a warning while its progress
9
+ # bars redraw: cases that failed, and what Braintrust, RubyLLM, OpenTelemetry
10
+ # (whose exporters report failures on $stdout) and Ruby itself log. Printed among the bars, each one breaks the redraw; collected
11
+ # here, they are saved to one file, and the run prints only how many.
12
+ #
13
+ # It quacks as an IO, so a Logger, and $stderr, can write to it. Threads
14
+ # share it.
15
+ class Warnings
16
+ # Starts empty, with the lock every reader and writer below shares.
17
+ def initialize
18
+ @lines = []
19
+ @lock = Mutex.new
20
+ end
21
+
22
+ # Keeps one warning, dropping trailing newlines and anything blank.
23
+ #
24
+ # @param text [#to_s]
25
+ # @return [self]
26
+ def record(text)
27
+ text = text.to_s.chomp
28
+ @lock.synchronize { @lines << text } unless text.strip.empty?
29
+ self
30
+ end
31
+
32
+ # IO#write, for a Logger or $stderr writing here. Each argument is one warning.
33
+ #
34
+ # @param texts [Array<#to_s>]
35
+ # @return [Integer] the bytes "written", as IO#write does
36
+ def write(*texts)
37
+ texts.each { record(it) }
38
+ texts.sum { it.to_s.bytesize }
39
+ end
40
+
41
+ # IO#puts, for code holding this as $stderr.
42
+ #
43
+ # @param texts [Array<#to_s>] one warning each
44
+ # @return [nil]
45
+ def puts(*texts) = texts.each { record(it) } && nil
46
+ # IO#print, for code holding this as $stderr.
47
+ #
48
+ # @param texts [Array<#to_s>] one warning each
49
+ # @return [nil]
50
+ def print(*texts) = write(*texts) && nil
51
+ # IO#<<, chainable as on an IO.
52
+ #
53
+ # @param text [#to_s]
54
+ # @return [self]
55
+ def <<(text) = record(text)
56
+ # IO#flush; nothing is buffered, so there is nothing to do.
57
+ #
58
+ # @return [self]
59
+ def flush = self
60
+ # IO#sync; records are always kept at once.
61
+ #
62
+ # @return [Boolean] always true
63
+ def sync = true
64
+ # IO#sync=; ignored, since {#sync} cannot be anything but true.
65
+ #
66
+ # @param _value [Boolean]
67
+ # @return [void]
68
+ def sync=(_value); end
69
+ # IO#close; ignored, so a Logger closing its device cannot lose the warnings.
70
+ #
71
+ # @return [nil]
72
+ def close = nil
73
+ # IO#tty?; false tells a writer not to emit terminal control codes here.
74
+ #
75
+ # @return [Boolean] always false
76
+ def tty? = false
77
+
78
+ # Safe to call while other threads are still recording.
79
+ #
80
+ # @return [Integer]
81
+ def count = @lock.synchronize { @lines.size }
82
+
83
+ # A copy, so a caller cannot change what was recorded.
84
+ #
85
+ # @return [Array<String>]
86
+ def to_a = @lock.synchronize { @lines.dup }
87
+
88
+ # Lets a caller skip saving an empty file.
89
+ #
90
+ # @return [Boolean] whether nothing was recorded
91
+ def none? = count.zero?
92
+
93
+ # Writes the warnings to a file, separated by blank lines, replacing what was there.
94
+ #
95
+ # @param path [String]
96
+ # @return [String] the path
97
+ def save(path)
98
+ File.write(path, "#{to_a.join("\n\n")}\n")
99
+ path
100
+ end
101
+
102
+ # Runs the block with $stderr, Ruby's own warnings, and the loggers of
103
+ # whichever optional gems are loaded writing here, and puts them back after.
104
+ #
105
+ # @example
106
+ # warnings.capture { warn "deprecated" }
107
+ # warnings.to_a # => ["deprecated"]
108
+ # @yield runs with the output redirected; everything is restored afterwards, even if it raises
109
+ # @return [Object] what the block returns
110
+ def capture
111
+ logger = Logger.new(self, level: Logger::WARN)
112
+ stderr = $stderr
113
+ kernel_warn = Kernel.instance_method(:warn)
114
+ warnings = self
115
+ restore = redirect_loggers(logger)
116
+ $stderr = self
117
+ Kernel.define_method(:warn) { |*messages, **_options| warnings.write(messages.join("\n")) }
118
+ Kernel.send(:private, :warn)
119
+ yield
120
+ ensure
121
+ Kernel.define_method(:warn, kernel_warn) if kernel_warn
122
+ Kernel.send(:private, :warn)
123
+ restore&.call
124
+ $stderr = stderr
125
+ end
126
+
127
+ private
128
+
129
+ # Points the optional gems' loggers at the one given, for the gems that are loaded.
130
+ #
131
+ # @param logger [Logger] writes here
132
+ # @return [Proc] puts the loggers back
133
+ def redirect_loggers(logger)
134
+ braintrust = defined?(Braintrust::Log) ? Braintrust::Log.logger : nil
135
+ ruby_llm = defined?(RubyLLM) ? RubyLLM.instance_variable_get(:@logger) : nil
136
+ open_telemetry = defined?(OpenTelemetry) ? OpenTelemetry.logger : nil
137
+ Braintrust::Log.logger = logger if defined?(Braintrust::Log)
138
+ RubyLLM.instance_variable_set(:@logger, logger) if defined?(RubyLLM)
139
+ OpenTelemetry.logger = logger if defined?(OpenTelemetry)
140
+ lambda do
141
+ Braintrust::Log.logger = braintrust if defined?(Braintrust::Log)
142
+ RubyLLM.instance_variable_set(:@logger, ruby_llm) if defined?(RubyLLM)
143
+ OpenTelemetry.logger = open_telemetry if defined?(OpenTelemetry)
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # What comes back: a provider's response, an adapter's reply, a score, and what a run held back.
6
+ module Results
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Runtime
6
+ # Everything a suite tells the gem, through {LLM::Evals.configure}.
7
+ # Paths are relative to `root`, the directory the eval helper lives in.
8
+ class Configuration
9
+ # Where cases are looked for, relative to the root, when a suite does not say.
10
+ DEFAULT_GLOB = "datasets/*/cases/**/input.txt"
11
+ # The suite's name until it calls {#suite}.
12
+ DEFAULT_NAME = "llm-evals"
13
+ # The lineup file's name, also searched for by {Helper.locate}.
14
+ DEFAULT_CONFIG = ".llm-evals.config.yml"
15
+
16
+ attr_writer :adapter, :scorer
17
+ attr_reader :name,
18
+ :root,
19
+ :env_files,
20
+ :cases_glob,
21
+ :hooks,
22
+ :providers,
23
+ :trackers,
24
+ :description,
25
+ :config_path,
26
+ :started_in
27
+
28
+ # @!attribute [w] adapter
29
+ # Registers the adapter, as {#adapter} does with an argument.
30
+ # @param value [Actions::Adapter, nil]
31
+ # @return [Actions::Adapter, nil]
32
+
33
+ # @!attribute [w] scorer
34
+ # Registers the scorer, as {#scorer} does with an argument.
35
+ # @param value [Scoring::Scorer, nil]
36
+ # @return [Scoring::Scorer, nil]
37
+
38
+ # @!attribute [r] name
39
+ # Titles the help and tags the experiments.
40
+ # @return [String]
41
+
42
+ # @!attribute [r] root
43
+ # The absolute directory other paths are relative to; the helper's directory.
44
+ # @return [String]
45
+
46
+ # @!attribute [r] env_files
47
+ # Absolute paths of the dotenv files to load, whether or not they exist.
48
+ # @return [Array<String>]
49
+
50
+ # @!attribute [r] cases_glob
51
+ # Finds each case's input file, relative to the root.
52
+ # @return [String]
53
+
54
+ # @!attribute [r] hooks
55
+ # The {#before_run} blocks, in the order given.
56
+ # @return [Array<Proc>]
57
+
58
+ # @!attribute [r] providers
59
+ # The defaults, plus any added through {#provider}.
60
+ # @return [Providers::Registry]
61
+
62
+ # @!attribute [r] trackers
63
+ # The defaults, plus any added through {#tracker}.
64
+ # @return [Trackers::Registry]
65
+
66
+ # @!attribute [r] description
67
+ # One line for the help's header.
68
+ # @return [String]
69
+
70
+ # @!attribute [r] config_path
71
+ # The lineup file as set, which {#config} resolves against the root.
72
+ # @return [String]
73
+
74
+ # @!attribute [r] started_in
75
+ # The directory the command was typed in, which the search may have left.
76
+ # @return [String]
77
+
78
+ # Starts under the default name at the working directory, with no adapter or scorer.
79
+ def initialize
80
+ @name = DEFAULT_NAME
81
+ @root = Dir.pwd
82
+ @env_files = []
83
+ @cases_glob = DEFAULT_GLOB
84
+ @config_path = DEFAULT_CONFIG
85
+ @hooks = []
86
+ @providers = Providers::Registry.default
87
+ @trackers = Trackers::Registry.default
88
+ @description = "Run a suite of cases against one or more models and score the replies."
89
+ @project = nil
90
+ @started_in = Dir.pwd
91
+ @search = false
92
+ end
93
+
94
+ # Names the suite. The name titles the help and tags the experiments.
95
+ #
96
+ # @param name [String]
97
+ # @param root [String] where .llm-evals.config.yml and datasets/ live; the helper's directory
98
+ # @param env_files [Array<String>] dotenv files to load, relative to root; the first that exists wins nothing,
99
+ # all that exist load, and an exported variable beats them all
100
+ # @param description [String, nil] one line for the help's header
101
+ # @return [void]
102
+ def suite(name, root:, env_files: [], description: nil)
103
+ @name = name
104
+ @root = File.expand_path(root)
105
+ @env_files = env_files.map { File.expand_path(it, @root) }
106
+ @description = description if description
107
+ end
108
+
109
+ # @param path [String, nil] setting, relative to root; reading when nil
110
+ # @return [String] the absolute path of the lineup file, `.llm-evals.config.yml` at the root by default
111
+ def config(path = nil)
112
+ @config_path = path unless path.nil?
113
+ File.expand_path(@config_path, root)
114
+ end
115
+
116
+ # How many directories below the working directory the search goes.
117
+ MAX_SEARCH_DEPTH = 4
118
+
119
+ # Directories that hold other people's files or generated ones, never a suite.
120
+ SEARCH_SKIPS = %w[node_modules vendor tmp log pkg coverage].freeze
121
+
122
+ # The nearest `.llm-evals.config.yml` below a directory: the shallowest,
123
+ # then the first by name, so that the same tree always gives the same file.
124
+ # Hidden directories and {SEARCH_SKIPS} are not entered.
125
+ #
126
+ # @param from [String] the directory to search below
127
+ # @param max_depth [Integer] how many directories down to look
128
+ # @return [String, nil] the file's path, or nil when there is none
129
+ def self.search_config_file(from: Dir.pwd, max_depth: MAX_SEARCH_DEPTH)
130
+ level = [from]
131
+ max_depth.times do
132
+ level = level.flat_map { search_subdirectories(it) }
133
+ found = level.map { File.join(it, DEFAULT_CONFIG) }.find { File.file?(it) }
134
+ return found if found
135
+ end
136
+ nil
137
+ end
138
+
139
+ # @param dir [String]
140
+ # @return [Array<String>] the directories to enter, by name; none when unreadable
141
+ def self.search_subdirectories(dir)
142
+ Dir.children(dir).sort
143
+ .reject { it.start_with?(".") || SEARCH_SKIPS.include?(it) }
144
+ .map { File.join(dir, it) }
145
+ .select { File.directory?(it) && !File.symlink?(it) }
146
+ rescue SystemCallError
147
+ []
148
+ end
149
+ private_class_method :search_subdirectories
150
+
151
+ # Records where the command started, since the launcher may change directory to run it.
152
+ #
153
+ # @param dir [String] the directory the command was typed in
154
+ # @param search [Boolean] whether a search was allowed to run
155
+ # @return [void]
156
+ def located_from(dir, search:)
157
+ @started_in = dir
158
+ @search = search
159
+ end
160
+
161
+ # @return [Boolean] whether the lineup file exists
162
+ def config_found? = File.file?(config)
163
+
164
+ # @return [String] the lineup file's path, relative to where the command was typed
165
+ def relative_config = Pathname(config).relative_path_from(started_in).to_s
166
+
167
+ # @return [String] what to say when there is no lineup file
168
+ def config_missing_message
169
+ message = "Configuration file #{DEFAULT_CONFIG} was not found"
170
+ @search ? "#{message}, and the search #{MAX_SEARCH_DEPTH} directories down failed too" : message
171
+ end
172
+
173
+ # Where the cases are: a glob relative to root ending in the input file.
174
+ # Each match is a case; the expected answer is `expected.json` beside it.
175
+ #
176
+ # @param glob [String]
177
+ # @return [void]
178
+ def cases(glob)
179
+ @cases_glob = glob
180
+ end
181
+
182
+ # @param object [Actions::Adapter, nil] setting; reading when nil
183
+ # @return [Actions::Adapter, nil] nil until one is registered
184
+ def adapter(object = nil)
185
+ @adapter = object unless object.nil?
186
+ @adapter
187
+ end
188
+
189
+ # @param object [Scoring::Scorer, nil] setting; reading when nil
190
+ # @return [Scoring::Scorer, nil] nil until one is registered
191
+ def scorer(object = nil)
192
+ @scorer = object unless object.nil?
193
+ @scorer
194
+ end
195
+
196
+ # Runs once, after the env files are loaded and before any model is
197
+ # called. The place to reset a library that read ENV at require time.
198
+ #
199
+ # @param block [Proc] the hook
200
+ # @yield once, with no arguments
201
+ # @return [void]
202
+ def before_run(&block)
203
+ @hooks << block
204
+ end
205
+
206
+ # Adds a provider, ahead of the defaults, so a suite's own transport
207
+ # wins the ids it claims.
208
+ #
209
+ # @param provider [Providers::Base]
210
+ # @return [void]
211
+ def provider(provider)
212
+ @providers.register(provider)
213
+ end
214
+
215
+ # Adds a tracker class, so a run can choose it by name with `--tracker`
216
+ # or `tracker:`, or have its env key select it.
217
+ #
218
+ # @param klass [Class<Trackers::Base>]
219
+ # @return [void]
220
+ def tracker(klass)
221
+ @trackers.register(klass)
222
+ end
223
+
224
+ # @param name [String, nil] setting; reading when nil
225
+ # @return [String, nil] the Braintrust project, when .llm-evals.config.yml does not name one
226
+ def project(name = nil)
227
+ @project = name unless name.nil?
228
+ @project
229
+ end
230
+
231
+ # @return [Datasets::Discovery] the cases, as the glob finds them
232
+ def discovery = Datasets::Discovery.new(root: root, glob: cases_glob)
233
+
234
+ # @return [Config] the lineup file
235
+ def lineup = Config.new(config, providers:)
236
+
237
+ # @return [Environment]
238
+ def runtime = Environment.new(env_files: env_files, hooks: hooks, app_name: name)
239
+
240
+ # @return [Boolean] whether an adapter and a scorer are registered
241
+ def complete? = !adapter.nil? && !scorer.nil?
242
+
243
+ # Fails a command that needs the suite when no helper was loaded, with
244
+ # the fix in the message, instead of a NoMethodError on nil.
245
+ #
246
+ # @return [self]
247
+ # @raise [Error]
248
+ def require_suite!
249
+ resolve_from_config!
250
+ return self if complete?
251
+
252
+ raise Error,
253
+ "no suite is configured: register an adapter and a scorer in evals/eval_helper.rb, or set " \
254
+ "adapter: and scorer: (with require:) in #{config}; --helper PATH or LLM_EVALS_HELPER names another helper"
255
+ end
256
+
257
+ # Fills whatever the helper left unset from the config file's `require:`,
258
+ # `adapter:` and `scorer:` keys, so a suite can be config-only: its Ruby
259
+ # lives in its own gem and `evals/` holds the file and the datasets. The
260
+ # helper, when there is one, was loaded first and wins.
261
+ #
262
+ # @return [void]
263
+ # @raise [Error] naming the key and the value when a require fails or a constant is unknown
264
+ def resolve_from_config!
265
+ return if complete? || !File.exist?(config)
266
+
267
+ file = lineup
268
+ file.requires.each do |feature|
269
+ require feature
270
+ rescue LoadError => e
271
+ raise Error, "#{config}: require: #{feature} could not be loaded (#{e.message}); is its gem in the bundle?"
272
+ end
273
+ @adapter ||= instantiate("adapter", file.adapter_name)
274
+ @scorer ||= instantiate("scorer", file.scorer_name) # rubocop:disable Naming/MemoizedInstanceVariableName
275
+ end
276
+
277
+ private
278
+
279
+ # @param key [String] the config key, for the error
280
+ # @param name [String, nil] the constant's name
281
+ # @return [Object, nil] a new instance of the named constant; nil when no name is set
282
+ # @raise [Error] when the constant is unknown or wants arguments
283
+ def instantiate(key, name)
284
+ return nil if name.nil? || name.empty?
285
+
286
+ Object.const_get(name).new
287
+ rescue NameError, ArgumentError => e
288
+ raise Error,
289
+ "#{config}: #{key}: #{name} could not be instantiated (#{e.class}: #{e.message}); " \
290
+ "name a constant the require: key loads, taking no arguments"
291
+ end
292
+ end
293
+ end
294
+ end
295
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dotenv"
4
+
5
+ module LLM
6
+ module Evals
7
+ module Runtime
8
+ # One-time process setup: secrets from the suite's env files, a check that
9
+ # every key and gem the run needs is there, the suite's own hooks, and
10
+ # the providers' clients.
11
+ class Environment
12
+ attr_reader :env_files, :hooks, :app_name
13
+
14
+ # @!attribute [r] env_files
15
+ # Absolute paths; those that exist load.
16
+ # @return [Array<String>]
17
+
18
+ # @!attribute [r] hooks
19
+ # The suite's `before_run` blocks.
20
+ # @return [Array<Proc>]
21
+
22
+ # @!attribute [r] app_name
23
+ # What a provider tells its API the caller is.
24
+ # @return [String]
25
+
26
+ # @param env_files [Array<String>] absolute paths; those that exist load
27
+ # @param hooks [Array<Proc>] the suite's `before_run` blocks
28
+ # @param app_name [String] what a provider tells its API the caller is
29
+ def initialize(env_files: [], hooks: [], app_name: "llm-evals")
30
+ @env_files = env_files
31
+ @hooks = hooks
32
+ @app_name = app_name
33
+ end
34
+
35
+ # Loads the env files that exist, without overriding what is already
36
+ # set, so an exported variable beats the file.
37
+ #
38
+ # @return [void]
39
+ def load_env
40
+ files = env_files.select { File.exist?(it) }
41
+ Dotenv.load(*files) unless files.empty?
42
+ end
43
+
44
+ # Fails before anything is called when a key the run needs is neither
45
+ # exported nor in an env file.
46
+ #
47
+ # @param names [Array<String>] variable names; repeats are fine
48
+ # @return [void]
49
+ # @raise [Error] naming every missing variable
50
+ def require_env!(names)
51
+ missing = names.uniq.select { ENV[it].to_s.strip.empty? }
52
+ return if missing.empty?
53
+
54
+ found = env_files.select { File.exist?(it) }
55
+ where = if found.empty?
56
+ env_files.empty? ? "no env files are configured" : "there is no #{env_files.join(" or ")}"
57
+ else
58
+ "checked the environment, then #{found.join(", ")}"
59
+ end
60
+ raise Error,
61
+ "missing #{missing.join(", ")}: export them or add them to an env file (#{where}); " \
62
+ "an exported blank value wins over the file"
63
+ end
64
+
65
+ # Readies the process for a run: env files, checks, hooks, then provider clients, in that order.
66
+ #
67
+ # @param providers [Array<Providers::Base>] the providers the run's models use
68
+ # @param tracker [Class<Trackers::Base>, nil] the remote tracker chosen, whose gems and key are checked too
69
+ # @return [void]
70
+ # @raise [Error] when a needed variable or gem is missing
71
+ def setup!(providers: [], tracker: nil)
72
+ load_env
73
+ require_env!(providers.flat_map(&:env_keys))
74
+ tracker&.requirements!(ENV)
75
+ hooks.each(&:call) # after load_env: a hook usually re-reads ENV
76
+ providers.each { it.setup!(app_name: app_name) }
77
+ end
78
+
79
+ # Opens a URL in the desktop browser.
80
+ #
81
+ # @param url [String] the page to open
82
+ # @return [Boolean] whether the opener ran
83
+ def browse(url)
84
+ opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
85
+ system(opener, url, out: File::NULL, err: File::NULL) || false
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Runtime
6
+ # Finds and loads the suite: `evals/eval_helper.rb`, the file that calls
7
+ # {LLM::Evals.configure}, or, for a config-only suite, the directory
8
+ # holding `evals/.llm-evals.config.yml`. The executable looks before
9
+ # dispatching, the way rspec finds spec_helper, so the suite's own
10
+ # directory needs no wrapper script.
11
+ module Helper
12
+ # Where the helper lives, relative to a directory searched by {.locate}.
13
+ FILE = File.join("evals", "eval_helper.rb")
14
+ # The variable that names a helper, when the flag does not.
15
+ ENV_VAR = "LLM_EVALS_HELPER"
16
+ # The flag that names a helper, read here because no command declares it.
17
+ FLAG = "--helper"
18
+ # Turns off the search below the working directory.
19
+ NO_SEARCH_FLAG = "--no-search"
20
+ # The flags naming a lineup file, which makes a search pointless.
21
+ CONFIG_FLAGS = %w[-C --config].freeze
22
+ # The directory a suite's files live in, under the project.
23
+ SUITE_DIR = "evals"
24
+
25
+ # What {.locate} found: a helper to load, a config file to read, or neither.
26
+ #
27
+ # @!attribute [r] helper
28
+ # @return [String, nil] the path of the helper to load
29
+ # @!attribute [r] config
30
+ # @return [String, nil] the path of the config file
31
+ # @!attribute [r] home
32
+ # @return [String, nil] the directory to run in, when the search found the suite below this one
33
+ Found = Data.define(:helper, :config, :home) do
34
+ # Either may be left out, and {.locate} returns neither when it finds nothing.
35
+ #
36
+ # @param helper [String, nil] the path of the helper to load
37
+ # @param config [String, nil] the path of the config file
38
+ # @param home [String, nil] the directory to run in
39
+ def initialize(helper: nil, config: nil, home: nil) = super
40
+
41
+ # @return [Boolean] whether there is anything to load
42
+ def any? = !helper.nil? || !config.nil?
43
+
44
+ # @return [Boolean] whether there is nothing to load
45
+ def none? = !any?
46
+ end
47
+
48
+ module_function
49
+
50
+ # Pulls `--helper PATH` (or `--helper=PATH`) out of argv, so dry-cli
51
+ # never sees a flag no command declares.
52
+ #
53
+ # @param argv [Array<String>] the command line; left unchanged
54
+ # @return [Array(Array<String>, String, nil)] the remaining arguments and the path, if given
55
+ def extract(argv)
56
+ rest = []
57
+ path = nil
58
+ skip = false
59
+ argv.each_with_index do |arg, index|
60
+ next skip = false if skip
61
+
62
+ if arg == FLAG
63
+ path = argv[index + 1]
64
+ skip = true
65
+ elsif arg.start_with?("#{FLAG}=")
66
+ path = arg.delete_prefix("#{FLAG}=")
67
+ else
68
+ rest << arg
69
+ end
70
+ end
71
+ [rest, path]
72
+ end
73
+
74
+ # Pulls `--no-search` out of argv.
75
+ #
76
+ # @param argv [Array<String>] the command line; left unchanged
77
+ # @return [Array(Array<String>, Boolean)] the remaining arguments and whether a search may run
78
+ def extract_search(argv) = [argv - [NO_SEARCH_FLAG], !argv.include?(NO_SEARCH_FLAG)]
79
+
80
+ # @param argv [Array<String>] the command line
81
+ # @return [Boolean] whether it names a lineup file itself
82
+ def config_flag?(argv) = argv.any? { CONFIG_FLAGS.include?(it) || it.start_with?("--config=") }
83
+
84
+ # The directory a suite is run from: above `evals/`, or the config's own.
85
+ #
86
+ # @param config [String] the path of a config file
87
+ # @return [String]
88
+ def project_dir(config)
89
+ dir = File.dirname(config)
90
+ File.basename(dir) == SUITE_DIR ? File.dirname(dir) : dir
91
+ end
92
+
93
+ # The suite to load: the flag, then the variable, then, from the
94
+ # directory upward, the nearest `evals/eval_helper.rb` or, failing
95
+ # that, the nearest `evals/.llm-evals.config.yml` (also found from
96
+ # inside `evals/` itself), then, when allowed, a search below the directory.
97
+ #
98
+ # @param explicit [String, nil] what `--helper` was given
99
+ # @param from [String] the directory to search up from
100
+ # @param env [Hash] the environment
101
+ # @param search [Boolean] whether to search below the directory when the rest finds nothing
102
+ # @return [Found] absolute paths; empty when there is none
103
+ def locate(explicit = nil, from: Dir.pwd, env: ENV, search: false)
104
+ candidate = explicit || env[ENV_VAR]
105
+ return Found.new(helper: File.expand_path(candidate, from)) if candidate && !candidate.empty?
106
+
107
+ Pathname(from).expand_path.ascend do |dir|
108
+ helper = dir.join(FILE)
109
+ config = [dir.join("evals", Configuration::DEFAULT_CONFIG), dir.join(Configuration::DEFAULT_CONFIG)].find(&:file?)
110
+ return Found.new(helper: helper.to_s, config: config&.to_s) if helper.file?
111
+ return Found.new(config: config.to_s) if config
112
+ end
113
+ search ? discover(from) : Found.new
114
+ end
115
+
116
+ # What the search below a directory found, with the helper beside the file, if any.
117
+ #
118
+ # @param from [String] the directory to search below
119
+ # @return [Found] with `home` set; empty when there is none
120
+ def discover(from)
121
+ config = Configuration.search_config_file(from: File.expand_path(from))
122
+ return Found.new unless config
123
+
124
+ helper = File.join(File.dirname(config), File.basename(FILE))
125
+ Found.new(helper: File.file?(helper) ? helper : nil, config: config, home: project_dir(config))
126
+ end
127
+
128
+ # Loads the helper, once, or roots the configuration at a config-only
129
+ # suite's directory so {Configuration#resolve_from_config!} can read it.
130
+ # A helper is loaded first and wins; the config fills what it left unset.
131
+ #
132
+ # @param found [Found, String, nil] nothing is loaded for nil or an empty Found
133
+ # @param configuration [Configuration]
134
+ # @return [Boolean] whether anything was loaded
135
+ # @raise [Error] when the helper does not exist
136
+ def load!(found, configuration: LLM::Evals.configuration)
137
+ found = Found.new(helper: found) if found.is_a?(String)
138
+ return false if found.nil? || found.none?
139
+
140
+ if found.helper
141
+ raise Error, "no eval helper at #{found.helper}" unless File.file?(found.helper)
142
+
143
+ require File.expand_path(found.helper)
144
+ else
145
+ root = File.dirname(found.config)
146
+ configuration.suite(File.basename(project_dir(found.config)), root: root) unless configuration.complete?
147
+ end
148
+ true
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end