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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE.txt +21 -0
- data/README.md +764 -0
- data/exe/evals +9 -0
- data/exe/llm-evals +14 -0
- data/lib/llm/evals/adapter.rb +47 -0
- data/lib/llm/evals/case.rb +36 -0
- data/lib/llm/evals/cli/cases.rb +48 -0
- data/lib/llm/evals/cli/command.rb +163 -0
- data/lib/llm/evals/cli/compare.rb +40 -0
- data/lib/llm/evals/cli/formatting.rb +16 -0
- data/lib/llm/evals/cli/leaderboard.rb +183 -0
- data/lib/llm/evals/cli/model_colors.rb +23 -0
- data/lib/llm/evals/cli/model_command.rb +437 -0
- data/lib/llm/evals/cli/progress_bars.rb +147 -0
- data/lib/llm/evals/cli/render.rb +37 -0
- data/lib/llm/evals/cli/run.rb +69 -0
- data/lib/llm/evals/cli/standings.rb +114 -0
- data/lib/llm/evals/cli/verify.rb +24 -0
- data/lib/llm/evals/cli/version.rb +15 -0
- data/lib/llm/evals/cli.rb +43 -0
- data/lib/llm/evals/completion.rb +11 -0
- data/lib/llm/evals/config.rb +198 -0
- data/lib/llm/evals/configuration.rb +173 -0
- data/lib/llm/evals/contender.rb +31 -0
- data/lib/llm/evals/discovery.rb +95 -0
- data/lib/llm/evals/experiment.rb +158 -0
- data/lib/llm/evals/helper.rb +98 -0
- data/lib/llm/evals/launcher.rb +37 -0
- data/lib/llm/evals/metric.rb +26 -0
- data/lib/llm/evals/model.rb +55 -0
- data/lib/llm/evals/providers/base.rb +93 -0
- data/lib/llm/evals/providers/catalog.rb +32 -0
- data/lib/llm/evals/providers/open_router.rb +64 -0
- data/lib/llm/evals/providers/registry.rb +112 -0
- data/lib/llm/evals/providers/type_safe.rb +53 -0
- data/lib/llm/evals/render/reports/html.rb +266 -0
- data/lib/llm/evals/render/reports/json.rb +172 -0
- data/lib/llm/evals/render/reports/pipeline.rb +153 -0
- data/lib/llm/evals/render/reports/terminal.rb +46 -0
- data/lib/llm/evals/reply.rb +18 -0
- data/lib/llm/evals/run_result.rb +14 -0
- data/lib/llm/evals/runtime.rb +74 -0
- data/lib/llm/evals/schema_validator.rb +84 -0
- data/lib/llm/evals/score.rb +26 -0
- data/lib/llm/evals/scorer.rb +45 -0
- data/lib/llm/evals/templates/html_report.css +206 -0
- data/lib/llm/evals/templates/html_report.html.erb +184 -0
- data/lib/llm/evals/templates/html_report.js +48 -0
- data/lib/llm/evals/testing/echo_adapter.rb +29 -0
- data/lib/llm/evals/testing/fake_model.rb +18 -0
- data/lib/llm/evals/testing/fake_provider.rb +67 -0
- data/lib/llm/evals/tracker.rb +80 -0
- data/lib/llm/evals/trackers/braintrust.rb +91 -0
- data/lib/llm/evals/trackers/local.rb +78 -0
- data/lib/llm/evals/trackers/logfire.rb +143 -0
- data/lib/llm/evals/trackers/registry.rb +82 -0
- data/lib/llm/evals/usage.rb +33 -0
- data/lib/llm/evals/verify.rb +49 -0
- data/lib/llm/evals/version.rb +7 -0
- data/lib/llm/evals/warnings.rb +98 -0
- data/lib/llm/evals/weighted_score.rb +116 -0
- data/lib/llm/evals.rb +83 -0
- metadata +274 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module LLM
|
|
8
|
+
module Evals
|
|
9
|
+
module Render
|
|
10
|
+
module Reports
|
|
11
|
+
# Writes the canonical JSON snapshot and passes that snapshot to the
|
|
12
|
+
# selected display. Temporary snapshots are removed after presentation.
|
|
13
|
+
class Pipeline
|
|
14
|
+
Result = Data.define(:json_path, :html_path, :css_path, :js_path, :browser_opened)
|
|
15
|
+
|
|
16
|
+
# @param providers [Providers::Registry]
|
|
17
|
+
# @param runtime [Runtime]
|
|
18
|
+
# @param destination [String]
|
|
19
|
+
# @param temp_dir [String]
|
|
20
|
+
# @param project [String]
|
|
21
|
+
def initialize(providers:, runtime:, destination:, temp_dir:, project:)
|
|
22
|
+
@providers = providers
|
|
23
|
+
@runtime = runtime
|
|
24
|
+
@destination = destination
|
|
25
|
+
@temp_dir = temp_dir
|
|
26
|
+
@project = project.to_s.downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-").delete_suffix("-")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param summaries [Array<Experiment::Summary>]
|
|
30
|
+
# @param metrics [Array<Metric>]
|
|
31
|
+
# @param weighted_score [WeightedScore, nil]
|
|
32
|
+
# @param run_options [Hash]
|
|
33
|
+
# @param autosave [Boolean]
|
|
34
|
+
# @param display [String]
|
|
35
|
+
# @param open [Boolean]
|
|
36
|
+
# @yield [report] display the decoded JSON in the terminal
|
|
37
|
+
# @return [Result]
|
|
38
|
+
def call(summaries:, metrics:, weighted_score:, run_options:, autosave:, display:, open:)
|
|
39
|
+
return Result.new(nil, nil, nil, nil, nil) unless autosave || display != "none"
|
|
40
|
+
|
|
41
|
+
stamp = timestamp
|
|
42
|
+
json_path = autosave ? artifact_path(@destination, stamp, "json") : temporary_json_path
|
|
43
|
+
write_json(summaries:, metrics:, weighted_score:, run_options:, path: json_path)
|
|
44
|
+
return Result.new(json_path:, html_path: nil, css_path: nil, js_path: nil, browser_opened: nil) if display == "none"
|
|
45
|
+
|
|
46
|
+
report = JSON.decode(File.read(json_path), providers: @providers)
|
|
47
|
+
assets = case display
|
|
48
|
+
when "tui"
|
|
49
|
+
yield report if block_given?
|
|
50
|
+
[nil, nil, nil, nil]
|
|
51
|
+
when "browser"
|
|
52
|
+
render_browser(report, open:)
|
|
53
|
+
else
|
|
54
|
+
[nil, nil, nil, nil]
|
|
55
|
+
end
|
|
56
|
+
html_path, css_path, js_path, browser_opened = assets
|
|
57
|
+
Result.new(json_path: autosave ? json_path : nil, html_path:, css_path:, js_path:, browser_opened:)
|
|
58
|
+
ensure
|
|
59
|
+
FileUtils.rm_f(json_path) if defined?(json_path) && json_path && !autosave
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param summaries [Array<Experiment::Summary>]
|
|
63
|
+
# @param metrics [Array<Metric>]
|
|
64
|
+
# @param weighted_score [WeightedScore, nil]
|
|
65
|
+
# @param run_options [Hash]
|
|
66
|
+
# @param path [String, nil] nil returns JSON text to the caller
|
|
67
|
+
# @return [String, nil] JSON text when no path was supplied
|
|
68
|
+
def write_json(summaries:, metrics:, weighted_score:, run_options:, path:)
|
|
69
|
+
document = JSON.dump(summaries, metrics:, weighted_score:, run_options:)
|
|
70
|
+
if path
|
|
71
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
72
|
+
File.write(path, document)
|
|
73
|
+
nil
|
|
74
|
+
else
|
|
75
|
+
document
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param json [String] serialized run results
|
|
80
|
+
# @param path [String, nil] nil returns HTML text to the caller
|
|
81
|
+
# @return [String, nil] HTML text when no path was supplied
|
|
82
|
+
def write_html(json:, path:)
|
|
83
|
+
report = JSON.decode(json, providers: @providers)
|
|
84
|
+
document = HTML.render(report)
|
|
85
|
+
if path
|
|
86
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
87
|
+
File.write(path, document)
|
|
88
|
+
nil
|
|
89
|
+
else
|
|
90
|
+
document
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Writes an explicitly requested JSON report under the configured
|
|
95
|
+
# destination, using the same timestamped name as an autosaved result.
|
|
96
|
+
#
|
|
97
|
+
# @param summaries [Array<Experiment::Summary>]
|
|
98
|
+
# @param metrics [Array<Metric>]
|
|
99
|
+
# @param weighted_score [WeightedScore, nil]
|
|
100
|
+
# @param run_options [Hash]
|
|
101
|
+
# @return [String] the saved JSON path
|
|
102
|
+
def save_json(summaries:, metrics:, weighted_score:, run_options:)
|
|
103
|
+
path = artifact_path(@destination, timestamp, "json")
|
|
104
|
+
write_json(summaries:, metrics:, weighted_score:, run_options:, path:)
|
|
105
|
+
path
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The name of a project's report, the same on every run, so the newest
|
|
109
|
+
# replaces the last. The JSON beside it keeps the timestamp, and
|
|
110
|
+
# `render` can rebuild any older report from it.
|
|
111
|
+
#
|
|
112
|
+
# @param directory [String]
|
|
113
|
+
# @param extension [String] html, or the css and js that go with it
|
|
114
|
+
# @return [String]
|
|
115
|
+
def report_path(directory, extension = "html")
|
|
116
|
+
File.join(directory, "#{@project}-llm-evals.report.#{extension}")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def render_browser(report, open:)
|
|
122
|
+
FileUtils.mkdir_p(@destination)
|
|
123
|
+
html_path = report_path(@destination, "html")
|
|
124
|
+
css_path = report_path(@destination, "css")
|
|
125
|
+
js_path = report_path(@destination, "js")
|
|
126
|
+
File.write(css_path, File.read(File.join(HTML::TEMPLATE_DIRECTORY, "html_report.css")))
|
|
127
|
+
File.write(js_path, File.read(File.join(HTML::TEMPLATE_DIRECTORY, "html_report.js")))
|
|
128
|
+
html = HTML.render(report,
|
|
129
|
+
stylesheet_path: File.basename(css_path),
|
|
130
|
+
script_path: File.basename(js_path))
|
|
131
|
+
File.write(html_path, html)
|
|
132
|
+
browser_opened = @runtime.browse(URI::DEFAULT_PARSER.escape("file://#{html_path}")) if open
|
|
133
|
+
[html_path, css_path, js_path, browser_opened]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def artifact_path(directory, stamp, extension)
|
|
137
|
+
File.join(directory, "#{@project}.results.#{stamp}.#{extension}")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def timestamp = Time.now.strftime("%Y-%m-%d.%H%M%S%6N")
|
|
141
|
+
|
|
142
|
+
def temporary_json_path
|
|
143
|
+
FileUtils.mkdir_p(@temp_dir)
|
|
144
|
+
file = Tempfile.create(["#{@project}.results.", ".json"], @temp_dir)
|
|
145
|
+
path = file.path
|
|
146
|
+
file.close
|
|
147
|
+
path
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Render
|
|
6
|
+
module Reports
|
|
7
|
+
# Presents a decoded report in the terminal and optionally writes the
|
|
8
|
+
# detailed view to a report file.
|
|
9
|
+
class Terminal
|
|
10
|
+
# @param invocation [Dry::CLI::UI::Invocation]
|
|
11
|
+
# @param writing_to [#call] redirects the command's table helpers
|
|
12
|
+
# @param leaderboard [#call] draws the summary leaderboard
|
|
13
|
+
def initialize(invocation:, writing_to:, leaderboard:)
|
|
14
|
+
@invocation = invocation
|
|
15
|
+
@writing_to = writing_to
|
|
16
|
+
@leaderboard = leaderboard
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param summaries [Array<Experiment::Summary>]
|
|
20
|
+
# @param output [String, nil]
|
|
21
|
+
# @param scores [Boolean]
|
|
22
|
+
# @param totals_mode [String] overall, variant or both
|
|
23
|
+
# @yield [summaries] draw the per-case detail
|
|
24
|
+
# @return [String, nil] report file path
|
|
25
|
+
def call(summaries:, output:, scores:, totals_mode:, &details)
|
|
26
|
+
path = @invocation.report_path(output)
|
|
27
|
+
if path
|
|
28
|
+
File.open(path, "w") do |file|
|
|
29
|
+
report = Dry::CLI::UI::Report.new(file)
|
|
30
|
+
@writing_to.call(report) do
|
|
31
|
+
details&.call(summaries)
|
|
32
|
+
@leaderboard.call(summaries, scores:, totals_mode:)
|
|
33
|
+
end
|
|
34
|
+
report.puts "Closed at #{Time.now.strftime("%Y-%m-%d %H:%M:%S %z")}"
|
|
35
|
+
end
|
|
36
|
+
else
|
|
37
|
+
details&.call(summaries)
|
|
38
|
+
end
|
|
39
|
+
@leaderboard.call(summaries, scores:, totals_mode:)
|
|
40
|
+
path
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
# What an {Adapter} returns for one case. `error` is the model's failure
|
|
6
|
+
# (it did not answer the question: no JSON, a refusal) and scores 0; a
|
|
7
|
+
# transport failure is not the model's and is raised instead, so it is
|
|
8
|
+
# counted as an error of the run rather than a wrong answer.
|
|
9
|
+
Reply = Data.define(:output, :raw, :usage, :error, :metadata) do
|
|
10
|
+
def initialize(output: nil, raw: nil, usage: Usage.zero, error: nil, metadata: {})
|
|
11
|
+
super(output: output, raw: raw, usage: Usage.from(usage), error: error, metadata: metadata)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [Boolean] whether the model failed to answer
|
|
15
|
+
def error? = !error.nil?
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
# One case, run once: what came back and what it scored. `reply` is nil
|
|
6
|
+
# when the adapter raised, and `error` then holds the message.
|
|
7
|
+
RunResult = Data.define(:experiment, :case, :reply, :score, :duration, :error) do
|
|
8
|
+
def initialize(experiment:, case:, reply: nil, score: nil, duration: 0.0, error: nil) = super
|
|
9
|
+
|
|
10
|
+
# @return [Boolean] whether the adapter raised rather than answered
|
|
11
|
+
def failed? = !error.nil?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dotenv"
|
|
4
|
+
|
|
5
|
+
module LLM
|
|
6
|
+
module Evals
|
|
7
|
+
# One-time process setup: secrets from the suite's env files, a check that
|
|
8
|
+
# every key and gem the run needs is there, the suite's own hooks, and
|
|
9
|
+
# the providers' clients.
|
|
10
|
+
class Runtime
|
|
11
|
+
attr_reader :env_files, :hooks, :app_name
|
|
12
|
+
|
|
13
|
+
# @param env_files [Array<String>] absolute paths; those that exist load
|
|
14
|
+
# @param hooks [Array<Proc>] the suite's `before_run` blocks
|
|
15
|
+
# @param app_name [String] what a provider tells its API the caller is
|
|
16
|
+
def initialize(env_files: [], hooks: [], app_name: "llm-evals")
|
|
17
|
+
@env_files = env_files
|
|
18
|
+
@hooks = hooks
|
|
19
|
+
@app_name = app_name
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Loads the env files that exist, without overriding what is already
|
|
23
|
+
# set, so an exported variable beats the file.
|
|
24
|
+
#
|
|
25
|
+
# @return [void]
|
|
26
|
+
def load_env
|
|
27
|
+
files = env_files.select { File.exist?(it) }
|
|
28
|
+
Dotenv.load(*files) unless files.empty?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Fails before anything is called when a key the run needs is neither
|
|
32
|
+
# exported nor in an env file.
|
|
33
|
+
#
|
|
34
|
+
# @param names [Array<String>]
|
|
35
|
+
# @return [void]
|
|
36
|
+
# @raise [Error] naming every missing variable
|
|
37
|
+
def require_env!(names)
|
|
38
|
+
missing = names.uniq.select { ENV[it].to_s.strip.empty? }
|
|
39
|
+
return if missing.empty?
|
|
40
|
+
|
|
41
|
+
found = env_files.select { File.exist?(it) }
|
|
42
|
+
where = if found.empty?
|
|
43
|
+
env_files.empty? ? "no env files are configured" : "there is no #{env_files.join(" or ")}"
|
|
44
|
+
else
|
|
45
|
+
"checked the environment, then #{found.join(", ")}"
|
|
46
|
+
end
|
|
47
|
+
raise Error,
|
|
48
|
+
"missing #{missing.join(", ")}: export them or add them to an env file (#{where}); " \
|
|
49
|
+
"an exported blank value wins over the file"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param providers [Array<Providers::Base>] the providers the run's models use
|
|
53
|
+
# @param tracker [Class<Tracker>, nil] the remote tracker chosen, whose gems and key are checked too
|
|
54
|
+
# @return [void]
|
|
55
|
+
# @raise [Error] when a needed variable or gem is missing
|
|
56
|
+
def setup!(providers: [], tracker: nil)
|
|
57
|
+
load_env
|
|
58
|
+
require_env!(providers.flat_map(&:env_keys))
|
|
59
|
+
tracker&.requirements!(ENV)
|
|
60
|
+
hooks.each(&:call) # after load_env: a hook usually re-reads ENV
|
|
61
|
+
providers.each { it.setup!(app_name: app_name) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Opens a URL in the desktop browser.
|
|
65
|
+
#
|
|
66
|
+
# @param url [String]
|
|
67
|
+
# @return [Boolean] whether the opener ran
|
|
68
|
+
def browse(url)
|
|
69
|
+
opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
|
|
70
|
+
system(opener, url, out: File::NULL, err: File::NULL) || false
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
# Checks a Hash of answers against a field map, for scorers and verifiers
|
|
6
|
+
# whose expected values are typed and closed. Nothing here is specific to
|
|
7
|
+
# what the fields mean.
|
|
8
|
+
#
|
|
9
|
+
# The map is `{ "name" => { type:, enum:, nullable: } }`; `type` is one of
|
|
10
|
+
# {TYPES}, `enum` lists the allowed values for enum and multi_enum, and a
|
|
11
|
+
# nullable field accepts nil (the text never answered it).
|
|
12
|
+
class SchemaValidator
|
|
13
|
+
TYPES = %i[boolean integer number string enum multi_enum].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :fields
|
|
16
|
+
|
|
17
|
+
# @param fields [Hash{String, Symbol => Hash}]
|
|
18
|
+
# @raise [ArgumentError] on a type outside {TYPES}, since a typo there would pass everything
|
|
19
|
+
def initialize(fields)
|
|
20
|
+
@fields = fields.to_h { |name, spec| [name.to_s, normalize(name, spec)] }.freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Array<String>] the field names
|
|
24
|
+
def names = fields.keys
|
|
25
|
+
|
|
26
|
+
# @param values [Hash] String or Symbol keys
|
|
27
|
+
# @return [Array<String>] every problem found; empty when the values fit the map
|
|
28
|
+
def problems(values)
|
|
29
|
+
return ["#{values.inspect} is not a Hash"] unless values.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
actual = values.transform_keys(&:to_s)
|
|
32
|
+
missing = names - actual.keys
|
|
33
|
+
extra = actual.keys - names
|
|
34
|
+
problems = []
|
|
35
|
+
problems << "missing #{missing.join(", ")}" if missing.any?
|
|
36
|
+
problems << "unknown fields #{extra.join(", ")}" if extra.any?
|
|
37
|
+
fields.each do |name, spec|
|
|
38
|
+
next unless actual.key?(name)
|
|
39
|
+
|
|
40
|
+
problem = value_problem(spec, actual[name])
|
|
41
|
+
problems << "#{name}: #{problem}" if problem
|
|
42
|
+
end
|
|
43
|
+
problems
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param values [Hash]
|
|
47
|
+
# @return [Boolean]
|
|
48
|
+
def valid?(values) = problems(values).empty?
|
|
49
|
+
|
|
50
|
+
# @param spec [Hash] one field's normalized spec
|
|
51
|
+
# @param value [Object]
|
|
52
|
+
# @return [String, nil] what is wrong with the value, or nil
|
|
53
|
+
def value_problem(spec, value)
|
|
54
|
+
return (spec[:nullable] ? nil : "is null") if value.nil?
|
|
55
|
+
|
|
56
|
+
case spec[:type]
|
|
57
|
+
when :boolean then "#{value.inspect} is not a boolean" unless [true, false].include?(value)
|
|
58
|
+
when :integer then "#{value.inspect} is not an integer" unless value.is_a?(Integer)
|
|
59
|
+
when :number then "#{value.inspect} is not a number" unless value.is_a?(Numeric)
|
|
60
|
+
when :string then "#{value.inspect} is not a string" unless value.is_a?(String)
|
|
61
|
+
when :enum then "#{value.inspect} is not one of #{spec[:enum].join("|")}" unless spec[:enum].include?(value)
|
|
62
|
+
when :multi_enum then multi_enum_problem(spec, value)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def multi_enum_problem(spec, value)
|
|
69
|
+
return "#{value.inspect} is not an array" unless value.is_a?(Array)
|
|
70
|
+
|
|
71
|
+
stray = value - spec[:enum]
|
|
72
|
+
"#{stray.join(", ")} not in #{spec[:enum].join("|")}" if stray.any?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def normalize(name, spec)
|
|
76
|
+
spec = spec.transform_keys(&:to_sym)
|
|
77
|
+
type = spec.fetch(:type).to_sym
|
|
78
|
+
raise ArgumentError, "#{name}: unknown type #{type.inspect}, expected one of #{TYPES.join(", ")}" unless TYPES.include?(type)
|
|
79
|
+
|
|
80
|
+
{ type: type, enum: Array(spec[:enum] || spec[:values]).map(&:to_s), nullable: spec.fetch(:nullable, false) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
# What a {Scorer} says about one reply. `value` is the case's overall
|
|
6
|
+
# score in 0..1; `metrics` holds one entry per {Metric} the scorer
|
|
7
|
+
# declares, nil where the case does not apply; `outcomes` and
|
|
8
|
+
# `diagnostics` are free-form, keyed by field, and only ever displayed.
|
|
9
|
+
Score = Data.define(:value, :metrics, :outcomes, :diagnostics) do
|
|
10
|
+
def initialize(value:, metrics: {}, outcomes: {}, diagnostics: {})
|
|
11
|
+
raise ArgumentError, "score value #{value.inspect} is not within 0..1" unless value.is_a?(Numeric) && (0..1).cover?(value)
|
|
12
|
+
|
|
13
|
+
super(value: value.to_f, metrics: metrics.transform_keys(&:to_s), outcomes: outcomes, diagnostics: diagnostics)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The score of a reply the model failed: 0 everywhere, the failure as a diagnostic.
|
|
17
|
+
#
|
|
18
|
+
# @param metrics [Array<Metric>]
|
|
19
|
+
# @param error [String]
|
|
20
|
+
# @return [Score]
|
|
21
|
+
def self.failed(metrics, error)
|
|
22
|
+
new(value: 0.0, metrics: metrics.to_h { [it.name, 0.0] }, diagnostics: { "error" => error })
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
# What a suite implements to score one reply. Pure and deterministic: it
|
|
6
|
+
# reads the case, the reply and the adapter's context, and calls nothing.
|
|
7
|
+
#
|
|
8
|
+
# @abstract Subclass and override {#metrics} and {#call}.
|
|
9
|
+
class Scorer
|
|
10
|
+
# The metrics every {Score} this scorer returns carries. The CLI builds
|
|
11
|
+
# its columns, its glossary and its Braintrust scorers from this list,
|
|
12
|
+
# so a metric missing here is a metric nobody sees.
|
|
13
|
+
#
|
|
14
|
+
# @return [Array<Metric>]
|
|
15
|
+
def metrics
|
|
16
|
+
raise NotImplementedError, "#{self.class}#metrics must return an Array of LLM::Evals::Metric"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Metric] the one flagged primary, else the first
|
|
20
|
+
def primary = metrics.find(&:primary) || metrics.first
|
|
21
|
+
|
|
22
|
+
# @param kase [Case]
|
|
23
|
+
# @param reply [Reply] never one with `error` set; the runner scores those 0 itself
|
|
24
|
+
# @param context [Hash] what {Adapter#context} returned for the case
|
|
25
|
+
# @return [Score]
|
|
26
|
+
def call(kase, reply, context:)
|
|
27
|
+
raise NotImplementedError, "#{self.class}#call(kase, reply, context:) must return an LLM::Evals::Score"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Offline checks of one case against what the scorer will demand of it,
|
|
31
|
+
# so a typo in expected.json is caught before it is billed as a model's mistake.
|
|
32
|
+
#
|
|
33
|
+
# @param kase [Case]
|
|
34
|
+
# @param context [Hash] what {Adapter#context} returned for the case
|
|
35
|
+
# @return [Array<String>] problems; empty when the case is sound
|
|
36
|
+
def verify(kase, context:) = [] # rubocop:disable Lint/UnusedMethodArgument -- the signature is the contract
|
|
37
|
+
|
|
38
|
+
# One short cell describing the case in `llm-evals cases`, e.g. "5/8 answered".
|
|
39
|
+
#
|
|
40
|
+
# @param kase [Case]
|
|
41
|
+
# @return [String, nil] nil for the generic fallback
|
|
42
|
+
def summary(_kase) = nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: dark;
|
|
3
|
+
--bg: #0b1014;
|
|
4
|
+
--panel: #121a20;
|
|
5
|
+
--panel-raised: #19232b;
|
|
6
|
+
--text: #e8eef1;
|
|
7
|
+
--muted: #93a3ad;
|
|
8
|
+
--line: #2a3942;
|
|
9
|
+
--blue: #73bfff;
|
|
10
|
+
--green: #55d79a;
|
|
11
|
+
--orange: #ffad5c;
|
|
12
|
+
--red: #ff746d;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
* { box-sizing: border-box; }
|
|
16
|
+
|
|
17
|
+
html { scroll-behavior: smooth; scroll-padding-top: 88px; }
|
|
18
|
+
|
|
19
|
+
body {
|
|
20
|
+
margin: 0;
|
|
21
|
+
background: var(--bg);
|
|
22
|
+
color: var(--text);
|
|
23
|
+
font: 15px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
main {
|
|
27
|
+
max-width: 1600px;
|
|
28
|
+
margin: auto;
|
|
29
|
+
padding: 36px 32px 84px;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.report-header {
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: flex-end;
|
|
35
|
+
justify-content: space-between;
|
|
36
|
+
gap: 24px;
|
|
37
|
+
margin-bottom: 22px;
|
|
38
|
+
scroll-margin-top: 90px;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.report-header .eyebrow { margin: 0 0 6px; color: var(--green); }
|
|
42
|
+
h1 { margin: 0; font-size: clamp(28px, 4vw, 42px); font-weight: 650; letter-spacing: -.035em; line-height: 1.12; }
|
|
43
|
+
h2 { margin: 0 0 8px; font-size: 21px; font-weight: 620; letter-spacing: -.02em; }
|
|
44
|
+
h3 { margin: 25px 0 10px; font-size: 15px; font-weight: 650; }
|
|
45
|
+
h4 { margin: 14px 0 6px; }
|
|
46
|
+
p { max-width: 78ch; }
|
|
47
|
+
.muted { color: var(--muted); }
|
|
48
|
+
.report-header .muted { margin: 9px 0 0; }
|
|
49
|
+
|
|
50
|
+
.top-link {
|
|
51
|
+
flex: none;
|
|
52
|
+
padding: 8px 12px;
|
|
53
|
+
border: 1px solid var(--line);
|
|
54
|
+
border-radius: 7px;
|
|
55
|
+
color: var(--muted);
|
|
56
|
+
text-decoration: none;
|
|
57
|
+
}
|
|
58
|
+
.top-link:hover, .top-link:focus-visible { border-color: var(--green); color: var(--text); }
|
|
59
|
+
|
|
60
|
+
.section-nav {
|
|
61
|
+
position: sticky;
|
|
62
|
+
z-index: 5;
|
|
63
|
+
top: 0;
|
|
64
|
+
display: flex;
|
|
65
|
+
gap: 5px;
|
|
66
|
+
margin: 0 -12px 22px;
|
|
67
|
+
padding: 9px 12px;
|
|
68
|
+
overflow-x: auto;
|
|
69
|
+
border: 1px solid var(--line);
|
|
70
|
+
border-radius: 9px;
|
|
71
|
+
background: rgb(11 16 20 / 94%);
|
|
72
|
+
box-shadow: 0 8px 24px rgb(0 0 0 / 22%);
|
|
73
|
+
backdrop-filter: blur(12px);
|
|
74
|
+
scrollbar-width: thin;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.section-nav a {
|
|
78
|
+
flex: none;
|
|
79
|
+
padding: 7px 11px;
|
|
80
|
+
border-radius: 6px;
|
|
81
|
+
color: var(--muted);
|
|
82
|
+
font-size: 13px;
|
|
83
|
+
text-decoration: none;
|
|
84
|
+
transition: color .15s ease, background-color .15s ease;
|
|
85
|
+
}
|
|
86
|
+
.section-nav a:hover, .section-nav a:focus-visible { background: var(--panel-raised); color: var(--text); outline: none; }
|
|
87
|
+
.section-nav a:focus-visible { box-shadow: 0 0 0 2px var(--blue); }
|
|
88
|
+
|
|
89
|
+
section,
|
|
90
|
+
.dataset {
|
|
91
|
+
margin: 16px 0;
|
|
92
|
+
padding: 20px;
|
|
93
|
+
border: 1px solid var(--line);
|
|
94
|
+
border-radius: 11px;
|
|
95
|
+
background: var(--panel);
|
|
96
|
+
scroll-margin-top: 82px;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.health {
|
|
100
|
+
display: grid;
|
|
101
|
+
grid-template-columns: repeat(auto-fit, minmax(155px, 1fr));
|
|
102
|
+
gap: 10px;
|
|
103
|
+
padding: 0;
|
|
104
|
+
border: 0;
|
|
105
|
+
background: none;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.stat {
|
|
109
|
+
min-height: 94px;
|
|
110
|
+
padding: 14px 16px;
|
|
111
|
+
border: 1px solid var(--line);
|
|
112
|
+
border-radius: 9px;
|
|
113
|
+
background: var(--panel);
|
|
114
|
+
}
|
|
115
|
+
.stat b { display: block; font-size: 24px; font-variant-numeric: tabular-nums; letter-spacing: -.025em; }
|
|
116
|
+
.stat span, .eyebrow { color: var(--muted); }
|
|
117
|
+
.stat span { font-size: 12px; }
|
|
118
|
+
|
|
119
|
+
.notice { padding: 12px 14px; border-left: 3px solid var(--red); background: #ff746d12; }
|
|
120
|
+
.leader {
|
|
121
|
+
display: grid;
|
|
122
|
+
gap: 5px;
|
|
123
|
+
margin: 18px 0;
|
|
124
|
+
padding: 17px 18px;
|
|
125
|
+
border: 1px solid #55d79a80;
|
|
126
|
+
border-radius: 9px;
|
|
127
|
+
background: #55d79a0d;
|
|
128
|
+
}
|
|
129
|
+
.leader.none { border-color: #ff746d80; background: #ff746d0d; }
|
|
130
|
+
.leader strong { font-size: 21px; }
|
|
131
|
+
.eyebrow { font-size: 11px; letter-spacing: .075em; }
|
|
132
|
+
.provider { color: var(--green); font-weight: 650; }
|
|
133
|
+
.model-id { color: var(--orange); font-weight: 700; }
|
|
134
|
+
.settings { display: block; color: var(--muted); font-size: 12px; }
|
|
135
|
+
|
|
136
|
+
.track { height: 12px; overflow: hidden; border-radius: 99px; background: var(--line); }
|
|
137
|
+
.track i { display: block; height: 100%; background: linear-gradient(90deg, var(--blue), var(--green)); }
|
|
138
|
+
|
|
139
|
+
table { width: 100%; min-width: 980px; border-collapse: collapse; font-size: 13px; }
|
|
140
|
+
th, td { padding: 10px 11px; border-top: 1px solid var(--line); text-align: left; vertical-align: top; }
|
|
141
|
+
th { color: var(--muted); font-size: 12px; font-weight: 550; white-space: nowrap; }
|
|
142
|
+
tbody tr:hover { background: rgb(255 255 255 / 2.5%); }
|
|
143
|
+
td { font-variant-numeric: tabular-nums; }
|
|
144
|
+
.sortable th { cursor: pointer; user-select: none; }
|
|
145
|
+
.sortable th::after { color: var(--muted); font-size: 11px; content: " ↕"; }
|
|
146
|
+
.sortable th[aria-sort="ascending"]::after { content: " ↑"; }
|
|
147
|
+
.sortable th[aria-sort="descending"]::after { content: " ↓"; }
|
|
148
|
+
.sortable th:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
|
|
149
|
+
.standings-table th > span { display: block; }
|
|
150
|
+
.standings-settings { color: var(--text); font-weight: 400; }
|
|
151
|
+
.standings-table .standings-bar { min-width: 300px; }
|
|
152
|
+
|
|
153
|
+
.scroll { max-width: 100%; overflow-x: auto; overscroll-behavior-inline: contain; -webkit-overflow-scrolling: touch; }
|
|
154
|
+
th:first-child, td:first-child { white-space: nowrap; }
|
|
155
|
+
.dataset { overflow: hidden; padding: 0; }
|
|
156
|
+
.dataset[open] { border-color: #3b505a; }
|
|
157
|
+
.dataset > summary, .case > summary {
|
|
158
|
+
display: flex;
|
|
159
|
+
justify-content: space-between;
|
|
160
|
+
gap: 16px;
|
|
161
|
+
padding: 15px 18px;
|
|
162
|
+
list-style: none;
|
|
163
|
+
cursor: pointer;
|
|
164
|
+
}
|
|
165
|
+
.dataset > summary { align-items: center; background: var(--panel-raised); }
|
|
166
|
+
.dataset > summary::before, .case > summary::before { content: "+"; color: var(--green); font-size: 13px; }
|
|
167
|
+
.dataset[open] > summary::before, .case[open] > summary::before { content: "−"; }
|
|
168
|
+
.dataset > summary::-webkit-details-marker, .case > summary::-webkit-details-marker { display: none; }
|
|
169
|
+
.dataset > summary strong { margin-right: auto; font-size: 16px; }
|
|
170
|
+
.dataset > summary span, .case > summary span { color: var(--muted); }
|
|
171
|
+
.dataset-content { padding: 0 18px 20px; }
|
|
172
|
+
.dataset-content > h3:first-child { margin-top: 20px; }
|
|
173
|
+
|
|
174
|
+
.case { overflow: hidden; margin: 8px 0; border: 1px solid var(--line); border-radius: 8px; }
|
|
175
|
+
.case > summary { align-items: center; padding: 10px 12px; }
|
|
176
|
+
.case-content { padding: 0 12px 12px; }
|
|
177
|
+
.case-content pre { max-height: 360px; overflow: auto; padding: 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
178
|
+
pre { font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
179
|
+
.case-content details { margin: 8px 0; }
|
|
180
|
+
.case-content details > summary { color: var(--blue); cursor: pointer; }
|
|
181
|
+
|
|
182
|
+
@media (prefers-reduced-motion: reduce) {
|
|
183
|
+
html { scroll-behavior: auto; }
|
|
184
|
+
*, *::before, *::after { transition-duration: .01ms !important; }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
@media (max-width: 900px) {
|
|
188
|
+
main { padding: 26px 20px 60px; }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
@media (max-width: 700px) {
|
|
192
|
+
main { padding: 20px 12px 42px; }
|
|
193
|
+
.report-header { align-items: flex-start; }
|
|
194
|
+
.top-link { display: none; }
|
|
195
|
+
.section-nav { margin-right: -4px; margin-left: -4px; }
|
|
196
|
+
section { padding: 15px; }
|
|
197
|
+
.dataset-content { padding: 0 10px 12px; }
|
|
198
|
+
.health { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
199
|
+
.stat { min-height: 80px; padding: 11px; }
|
|
200
|
+
.stat b { font-size: 20px; }
|
|
201
|
+
.dataset > summary { align-items: flex-start; flex-wrap: wrap; }
|
|
202
|
+
.dataset > summary span { flex-basis: 100%; padding-left: 22px; }
|
|
203
|
+
.case > summary { align-items: flex-start; flex-wrap: wrap; }
|
|
204
|
+
.case > summary span { flex-basis: 100%; padding-left: 22px; }
|
|
205
|
+
table { min-width: 900px; }
|
|
206
|
+
}
|