llm-evals 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +163 -87
- data/exe/llm-evals +60 -5
- data/lib/.DS_Store +0 -0
- data/lib/llm/.DS_Store +0 -0
- data/lib/llm/evals/actions/adapter.rb +50 -0
- data/lib/llm/evals/actions/error_budget.rb +63 -0
- data/lib/llm/evals/actions/error_diagnosis.rb +51 -0
- data/lib/llm/evals/actions/error_streak.rb +83 -0
- data/lib/llm/evals/actions/experiment.rb +251 -0
- data/lib/llm/evals/actions/verify.rb +61 -0
- data/lib/llm/evals/actions.rb +9 -0
- data/lib/llm/evals/cli/cases.rb +10 -0
- data/lib/llm/evals/cli/command.rb +32 -99
- data/lib/llm/evals/cli/compare.rb +0 -20
- data/lib/llm/evals/cli/contender_selector.rb +61 -0
- data/lib/llm/evals/cli/error_pause.rb +81 -0
- data/lib/llm/evals/cli/experiment_pool.rb +97 -0
- data/lib/llm/evals/cli/formatting.rb +8 -0
- data/lib/llm/evals/cli/leaderboard.rb +66 -12
- data/lib/llm/evals/cli/model_colors.rb +3 -0
- data/lib/llm/evals/cli/model_command.rb +152 -315
- data/lib/llm/evals/cli/model_options.rb +80 -0
- data/lib/llm/evals/cli/option_groups.rb +32 -0
- data/lib/llm/evals/cli/progress_bars.rb +92 -5
- data/lib/llm/evals/cli/render.rb +6 -6
- data/lib/llm/evals/cli/report_dispatcher/console.rb +65 -0
- data/lib/llm/evals/cli/report_dispatcher/html_output.rb +50 -0
- data/lib/llm/evals/cli/report_dispatcher/json_output.rb +29 -0
- data/lib/llm/evals/cli/report_dispatcher/output.rb +56 -0
- data/lib/llm/evals/cli/report_dispatcher/pipeline_output.rb +38 -0
- data/lib/llm/evals/cli/report_dispatcher.rb +133 -0
- data/lib/llm/evals/cli/run.rb +13 -22
- data/lib/llm/evals/cli/run_planner.rb +97 -0
- data/lib/llm/evals/cli/run_record.rb +68 -0
- data/lib/llm/evals/cli/standings.rb +39 -17
- data/lib/llm/evals/cli/status_box.rb +51 -0
- data/lib/llm/evals/cli/table.rb +103 -0
- data/lib/llm/evals/cli/text.rb +22 -0
- data/lib/llm/evals/cli/tracker_resolver.rb +52 -0
- data/lib/llm/evals/cli/upgrade_config.rb +60 -0
- data/lib/llm/evals/cli/verify.rb +5 -1
- data/lib/llm/evals/cli/version.rb +4 -0
- data/lib/llm/evals/cli/weights.rb +48 -0
- data/lib/llm/evals/cli.rb +39 -6
- data/lib/llm/evals/config/contender_builder.rb +30 -0
- data/lib/llm/evals/config/error_limits.rb +41 -0
- data/lib/llm/evals/config/listing.rb +153 -0
- data/lib/llm/evals/config/results.rb +42 -0
- data/lib/llm/evals/config/score_weights.rb +25 -0
- data/lib/llm/evals/config/upgrade.rb +107 -0
- data/lib/llm/evals/config.rb +90 -79
- data/lib/llm/evals/datasets/case.rb +46 -0
- data/lib/llm/evals/datasets/discovery.rb +143 -0
- data/lib/llm/evals/datasets.rb +9 -0
- data/lib/llm/evals/launcher.rb +57 -3
- data/lib/llm/evals/models/contender.rb +51 -0
- data/lib/llm/evals/models/model.rb +87 -0
- data/lib/llm/evals/models/throttle.rb +170 -0
- data/lib/llm/evals/models.rb +9 -0
- data/lib/llm/evals/providers/base.rb +61 -16
- data/lib/llm/evals/providers/catalog.rb +5 -2
- data/lib/llm/evals/providers/open_router.rb +54 -7
- data/lib/llm/evals/providers/registry.rb +49 -16
- data/lib/llm/evals/providers/type_safe/client.rb +67 -0
- data/lib/llm/evals/providers/type_safe.rb +113 -12
- data/lib/llm/evals/providers.rb +9 -0
- data/lib/llm/evals/render/reports/call_stats_table.rb +44 -0
- data/lib/llm/evals/render/reports/html/dataset_section.rb +51 -0
- data/lib/llm/evals/render/reports/html/format.rb +38 -0
- data/lib/llm/evals/render/reports/html/global_standings.rb +41 -0
- data/lib/llm/evals/render/reports/html/ranking.rb +86 -0
- data/lib/llm/evals/render/reports/html/view_model.rb +116 -0
- data/lib/llm/evals/render/reports/html.rb +60 -181
- data/lib/llm/evals/render/reports/json/decoder.rb +137 -0
- data/lib/llm/evals/render/reports/json/encoder.rb +106 -0
- data/lib/llm/evals/render/reports/json.rb +38 -141
- data/lib/llm/evals/render/reports/pipeline.rb +55 -20
- data/lib/llm/evals/render/reports/terminal.rb +8 -1
- data/lib/llm/evals/render.rb +9 -0
- data/lib/llm/evals/results/provider_response.rb +25 -0
- data/lib/llm/evals/results/reply.rb +38 -0
- data/lib/llm/evals/results/run_outcome.rb +15 -0
- data/lib/llm/evals/results/run_result.rb +40 -0
- data/lib/llm/evals/results/score.rb +45 -0
- data/lib/llm/evals/results/warnings.rb +149 -0
- data/lib/llm/evals/results.rb +9 -0
- data/lib/llm/evals/runtime/configuration.rb +295 -0
- data/lib/llm/evals/runtime/environment.rb +90 -0
- data/lib/llm/evals/runtime/helper.rb +153 -0
- data/lib/llm/evals/runtime.rb +2 -67
- data/lib/llm/evals/scoring/metric.rb +47 -0
- data/lib/llm/evals/scoring/schema_validator.rb +102 -0
- data/lib/llm/evals/scoring/scorer.rb +49 -0
- data/lib/llm/evals/scoring/weighted_score.rb +148 -0
- data/lib/llm/evals/scoring.rb +9 -0
- data/lib/llm/evals/stats/call_stats.rb +91 -0
- data/lib/llm/evals/stats/usage.rb +46 -0
- data/lib/llm/evals/stats.rb +29 -0
- data/lib/llm/evals/testing/echo_adapter.rb +14 -9
- data/lib/llm/evals/testing/fake_model.rb +8 -6
- data/lib/llm/evals/testing/fake_provider.rb +39 -8
- data/lib/llm/evals/testing.rb +9 -0
- data/lib/llm/evals/trackers/base.rb +110 -0
- data/lib/llm/evals/trackers/braintrust.rb +40 -3
- data/lib/llm/evals/trackers/local.rb +38 -2
- data/lib/llm/evals/trackers/logfire.rb +56 -3
- data/lib/llm/evals/trackers/registry.rb +29 -7
- data/lib/llm/evals/trackers.rb +9 -0
- data/lib/llm/evals/version.rb +3 -1
- data/lib/llm/evals.rb +17 -7
- metadata +91 -24
- data/lib/llm/evals/adapter.rb +0 -47
- data/lib/llm/evals/case.rb +0 -36
- data/lib/llm/evals/completion.rb +0 -11
- data/lib/llm/evals/configuration.rb +0 -173
- data/lib/llm/evals/contender.rb +0 -31
- data/lib/llm/evals/discovery.rb +0 -95
- data/lib/llm/evals/experiment.rb +0 -158
- data/lib/llm/evals/helper.rb +0 -98
- data/lib/llm/evals/metric.rb +0 -26
- data/lib/llm/evals/model.rb +0 -55
- data/lib/llm/evals/reply.rb +0 -18
- data/lib/llm/evals/run_result.rb +0 -14
- data/lib/llm/evals/schema_validator.rb +0 -84
- data/lib/llm/evals/score.rb +0 -26
- data/lib/llm/evals/scorer.rb +0 -45
- data/lib/llm/evals/tracker.rb +0 -80
- data/lib/llm/evals/usage.rb +0 -33
- data/lib/llm/evals/verify.rb +0 -49
- data/lib/llm/evals/warnings.rb +0 -98
- data/lib/llm/evals/weighted_score.rb +0 -116
- /data/lib/llm/evals/{templates → render/templates}/html_report.css +0 -0
- /data/lib/llm/evals/{templates → render/templates}/html_report.html.erb +0 -0
- /data/lib/llm/evals/{templates → render/templates}/html_report.js +0 -0
|
@@ -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,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
|