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
data/exe/evals
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# No Bundler here: a Gemfile in the user's directory must not decide which
|
|
5
|
+
# gems load. The suite's own eval_helper.rb may `require "bundler/setup"`.
|
|
6
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
7
|
+
require "llm/evals"
|
|
8
|
+
|
|
9
|
+
LLM::Evals::Launcher.new(ARGV).execute!
|
data/exe/llm-evals
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# No Bundler here: a Gemfile in the user's directory must not decide which
|
|
5
|
+
# gems load. The suite's own eval_helper.rb may `require "bundler/setup"`.
|
|
6
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
7
|
+
require "llm/evals"
|
|
8
|
+
|
|
9
|
+
puts "\e[1;31mERROR: \e[0;33mThis gem comes with an executable named 'evals'"
|
|
10
|
+
puts " You invoked 'llm-evals'. Please use 'evals' to use the gems's CLI.\n\e[0m"
|
|
11
|
+
|
|
12
|
+
puts "Thanks for using llm-evals!"
|
|
13
|
+
|
|
14
|
+
exit 0
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
# What a suite implements to run one case: everything the model sees, and
|
|
6
|
+
# everything done to its answer before it is scored. An adapter never
|
|
7
|
+
# scores; that is the {Scorer}'s, and keeping them apart is what lets one
|
|
8
|
+
# suite compare prompts and another compare models with the same runner.
|
|
9
|
+
#
|
|
10
|
+
# @abstract Subclass and override {#call}.
|
|
11
|
+
class Adapter
|
|
12
|
+
# Called once per dataset before its cases run; load what the dataset
|
|
13
|
+
# shares (a schema, a flow definition) here, outside the threads.
|
|
14
|
+
#
|
|
15
|
+
# @param dataset [String]
|
|
16
|
+
# @return [void]
|
|
17
|
+
def prepare(_dataset) = nil
|
|
18
|
+
|
|
19
|
+
# Runs one case against one model.
|
|
20
|
+
#
|
|
21
|
+
# @param kase [Case]
|
|
22
|
+
# @param model [Model]
|
|
23
|
+
# @return [Reply] with `error:` set when the model failed to answer; raise for a transport failure
|
|
24
|
+
def call(kase, model:)
|
|
25
|
+
raise NotImplementedError, "#{self.class}#call(kase, model:) must return an LLM::Evals::Reply"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# What the scorer needs to know about a case beyond the case itself: a
|
|
29
|
+
# schema, field types, the prompt. Passed to {Scorer#call} as `context:`.
|
|
30
|
+
#
|
|
31
|
+
# @param kase [Case]
|
|
32
|
+
# @return [Hash]
|
|
33
|
+
def context(_kase) = {}
|
|
34
|
+
|
|
35
|
+
# Offline checks of a dataset's shared files. `llm-evals verify` prints them.
|
|
36
|
+
#
|
|
37
|
+
# @param dataset [String]
|
|
38
|
+
# @return [Array<String>] problems; empty when the dataset is sound
|
|
39
|
+
def verify(_dataset) = []
|
|
40
|
+
|
|
41
|
+
# Recorded with each Braintrust experiment, e.g. which prompt version ran.
|
|
42
|
+
#
|
|
43
|
+
# @return [Hash]
|
|
44
|
+
def metadata = {}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
# One eval case, as {Discovery} found it on disk: the text the model is
|
|
6
|
+
# given, the answer a perfect reply would produce, and where it came from.
|
|
7
|
+
#
|
|
8
|
+
# @!attribute [r] id
|
|
9
|
+
# @return [String] unique within its dataset, e.g. "clear/003"
|
|
10
|
+
# @!attribute [r] dataset
|
|
11
|
+
# @return [String] the folder under datasets/, e.g. "colors"
|
|
12
|
+
# @!attribute [r] variant
|
|
13
|
+
# @return [String] the folder under cases/, e.g. "clear" or "vague"
|
|
14
|
+
# @!attribute [r] input
|
|
15
|
+
# @return [String] input.txt, stripped
|
|
16
|
+
# @!attribute [r] expected
|
|
17
|
+
# @return [Object, nil] expected.json parsed; nil when missing or unparsable
|
|
18
|
+
# @!attribute [r] path
|
|
19
|
+
# @return [String] the case's directory
|
|
20
|
+
# @!attribute [r] metadata
|
|
21
|
+
# @return [Hash{Symbol => Object}] number, difficulty, and any `expected_error`
|
|
22
|
+
Case = Data.define(:id, :dataset, :variant, :input, :expected, :path, :metadata) do
|
|
23
|
+
# @return [Integer, nil] the NNN/ level as a number, when the case has one
|
|
24
|
+
def number = metadata[:number]
|
|
25
|
+
|
|
26
|
+
# @return [Integer] the number, else 0; what the tables sort and show difficulty by
|
|
27
|
+
def difficulty = metadata.fetch(:difficulty, number || 0)
|
|
28
|
+
|
|
29
|
+
# @return [String] "dataset/id", unique across the suite
|
|
30
|
+
def key = "#{dataset}/#{id}"
|
|
31
|
+
|
|
32
|
+
# @return [Boolean] whether expected.json was there and parsed
|
|
33
|
+
def expected? = !expected.nil?
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module CLI
|
|
6
|
+
# The cases, without calling anything.
|
|
7
|
+
class Cases < Command
|
|
8
|
+
desc "List the eval cases in every dataset"
|
|
9
|
+
|
|
10
|
+
option :dataset, type: :array, aliases: ["-d"], default: [], desc: "Only these datasets, comma-separated"
|
|
11
|
+
|
|
12
|
+
def call(dataset: [], **)
|
|
13
|
+
scorer = configuration.scorer
|
|
14
|
+
names = dataset.flat_map { it.split(",") }
|
|
15
|
+
names = discovery.datasets if names.empty?
|
|
16
|
+
rows = names.flat_map { discovery.cases(dataset: it) }.map do |kase|
|
|
17
|
+
[kase.dataset, kase.id, kase.difficulty, scorer.summary(kase) || summary(kase), kase.input.split.size]
|
|
18
|
+
end
|
|
19
|
+
raise Error, "no cases found under #{discovery.root} with #{discovery.glob}" if rows.empty?
|
|
20
|
+
|
|
21
|
+
table(rows, header: %w[dataset case difficulty expected words])
|
|
22
|
+
refusals
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# Names, on STDERR, every lineup entry the suite cannot ask, so the
|
|
28
|
+
# mismatch is visible before anyone reaches for `run`.
|
|
29
|
+
def refusals
|
|
30
|
+
config = configuration.lineup
|
|
31
|
+
_, refused = configuration.providers.admit(config.contenders, request_types: config.request_types)
|
|
32
|
+
refused.map(&:to_s).uniq.each { status_box :warning, it, stream: err }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The generic cell when the scorer has nothing to say: how many fields
|
|
36
|
+
# the expected answer holds, or what kind of thing it is.
|
|
37
|
+
def summary(kase)
|
|
38
|
+
case kase.expected
|
|
39
|
+
when Hash then "#{kase.expected.size} fields"
|
|
40
|
+
when Array then "#{kase.expected.size} items"
|
|
41
|
+
when nil then "-"
|
|
42
|
+
else kase.expected.class.name.downcase
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require "pastel"
|
|
5
|
+
require "tty-box"
|
|
6
|
+
require "tty-table"
|
|
7
|
+
|
|
8
|
+
module LLM
|
|
9
|
+
module Evals
|
|
10
|
+
module CLI
|
|
11
|
+
# What every command inherits: the ui helpers, and the flags they all share.
|
|
12
|
+
class Command < Dry::CLI::Command
|
|
13
|
+
include Dry::CLI::UI
|
|
14
|
+
|
|
15
|
+
# A cell that reads as a number: 42, 1,200, 97.1%, 3.5s, $0.0042.
|
|
16
|
+
NUMBER = /\A-?\$?\d[\d,]*(?:\.\d+)?[%s]?\z/
|
|
17
|
+
|
|
18
|
+
# Cells that stand in for a number without being one; they do not stop
|
|
19
|
+
# a column from right-aligning.
|
|
20
|
+
PLACEHOLDERS = ["", "-", "default"].freeze
|
|
21
|
+
|
|
22
|
+
# The width assumed when the output is not a terminal.
|
|
23
|
+
DEFAULT_WIDTH = 120
|
|
24
|
+
|
|
25
|
+
# The narrowest a wrapped column gets, however full the screen.
|
|
26
|
+
MIN_WRAP = 30
|
|
27
|
+
|
|
28
|
+
# Each status box's title and colors.
|
|
29
|
+
STATUS = {
|
|
30
|
+
info: { title: " Info ", fg: :bright_white, bg: :blue },
|
|
31
|
+
success: { title: " Success ", fg: :black, bg: :green },
|
|
32
|
+
warning: { title: " Warning ", fg: :black, bg: :yellow },
|
|
33
|
+
error: { title: " Error ", fg: :bright_white, bg: :red }
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
def self.inherited(subclass)
|
|
37
|
+
super
|
|
38
|
+
subclass.option :verbose, type: :boolean, default: false, aliases: ["-v"], desc: "Print the full backtrace on failure"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# @return [Configuration] the suite, which must be loaded
|
|
44
|
+
def configuration = LLM::Evals.configuration.require_suite!
|
|
45
|
+
|
|
46
|
+
# @return [Discovery]
|
|
47
|
+
def discovery = configuration.discovery
|
|
48
|
+
|
|
49
|
+
# ui.table, as wide as the terminal (and so as the info and warning
|
|
50
|
+
# boxes), with every all-numeric column right-aligned and a line between
|
|
51
|
+
# rows once any cell spans several. dry-cli-ui does none of that, so this
|
|
52
|
+
# renders through tty-table, as it does.
|
|
53
|
+
#
|
|
54
|
+
# The fill column takes whatever width the others leave. With wrap, its
|
|
55
|
+
# text is word-wrapped to fit; without, it is only ever widened, so an
|
|
56
|
+
# id is never broken and a table too wide for the screen stays so.
|
|
57
|
+
#
|
|
58
|
+
# @param rows [Array<Array<#to_s>>]
|
|
59
|
+
# @param header [Array<String>]
|
|
60
|
+
# @param highlight [Array<Integer>] rows drawn in bold yellow (when color is on)
|
|
61
|
+
# @param fill [Integer] the column that absorbs the spare width
|
|
62
|
+
# @param wrap [Boolean] word-wrap the fill column to fit
|
|
63
|
+
# @return [void]
|
|
64
|
+
def table(rows, header:, highlight: [], fill: 1, wrap: false)
|
|
65
|
+
return if rows.empty?
|
|
66
|
+
|
|
67
|
+
cells = rows.map { |row| row.map(&:to_s) }
|
|
68
|
+
widths = column_widths([header] + cells)
|
|
69
|
+
spare = terminal_width - (widths.sum - widths[fill]) - ((3 * widths.size) + 1)
|
|
70
|
+
if wrap
|
|
71
|
+
widths[fill] = [spare, MIN_WRAP].max
|
|
72
|
+
cells.each { it[fill] = wrap(it[fill], widths[fill]) }
|
|
73
|
+
else
|
|
74
|
+
widths[fill] = [spare, widths[fill]].max
|
|
75
|
+
end
|
|
76
|
+
alignments = header.each_index.map do |index|
|
|
77
|
+
column = cells.map { it[index] }.reject { PLACEHOLDERS.include?(it) }
|
|
78
|
+
!column.empty? && column.all? { NUMBER.match?(it) } ? :right : :left
|
|
79
|
+
end
|
|
80
|
+
multiline = cells.flatten.any? { it.include?("\n") }
|
|
81
|
+
cells = cells.each_with_index.map { |row, index| highlight.include?(index) ? row.map { pastel.bold.yellow(it) } : row }
|
|
82
|
+
rendered = TTY::Table.new(header: header.map { pastel.bold(it) }, rows: cells).render(
|
|
83
|
+
:unicode,
|
|
84
|
+
padding: [0, 1],
|
|
85
|
+
width: Dry::CLI::UI::Widgets::Table::UNLIMITED,
|
|
86
|
+
alignments: alignments,
|
|
87
|
+
multiline: multiline,
|
|
88
|
+
column_widths: widths
|
|
89
|
+
) { |renderer| renderer.border.separator = :each_row if multiline }
|
|
90
|
+
out.puts rendered
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Array<Integer>] each column's widest line
|
|
94
|
+
def column_widths(rows)
|
|
95
|
+
rows.transpose.map { |column| column.flat_map { it.to_s.split("\n") }.map(&:length).max.to_i }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# ui.info, ui.success, ui.warn and ui.error, painted on their color
|
|
99
|
+
# across the full width of the terminal. dry-cli-ui colors only a box's
|
|
100
|
+
# title, so this draws the box with tty-box, which it uses too. Without
|
|
101
|
+
# color it is a plain framed box.
|
|
102
|
+
#
|
|
103
|
+
# @param level [Symbol] a key of {STATUS}
|
|
104
|
+
# @param text [String]
|
|
105
|
+
# @param stream [IO] out by default; warnings and errors go to err
|
|
106
|
+
# @return [void]
|
|
107
|
+
def status_box(level, text, stream: out)
|
|
108
|
+
look = STATUS.fetch(level)
|
|
109
|
+
colored = color?(stream)
|
|
110
|
+
style = colored ? { fg: look[:fg], bg: look[:bg], border: { fg: look[:fg], bg: look[:bg] } } : {}
|
|
111
|
+
# tty-box drops a word wider than the box, such as a long path; cut it first
|
|
112
|
+
stream.puts TTY::Box.frame(wrap(text, terminal_width - 4),
|
|
113
|
+
width: terminal_width,
|
|
114
|
+
padding: [0, 1],
|
|
115
|
+
title: { top_left: look[:title] },
|
|
116
|
+
style: style,
|
|
117
|
+
enable_color: colored)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A report written with -o is in color, as dry-cli-ui writes its own.
|
|
121
|
+
#
|
|
122
|
+
# @param stream [IO]
|
|
123
|
+
# @return [Boolean] whether the stream takes color
|
|
124
|
+
def color?(stream = out)
|
|
125
|
+
(stream.respond_to?(:color?) && stream.color?) || (stream.respond_to?(:tty?) && stream.tty? && !ENV.key?("NO_COLOR"))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def pastel = @pastel ||= Pastel.new(enabled: color?)
|
|
129
|
+
|
|
130
|
+
# @return [Integer] columns available on this terminal; {DEFAULT_WIDTH} when it is not one.
|
|
131
|
+
# While writing to a report, the screen's.
|
|
132
|
+
def terminal_width
|
|
133
|
+
screen = @screen || out
|
|
134
|
+
width = screen.respond_to?(:winsize) && screen.tty? ? screen.winsize[1] : 0
|
|
135
|
+
width.positive? ? width : DEFAULT_WIDTH
|
|
136
|
+
rescue SystemCallError
|
|
137
|
+
DEFAULT_WIDTH
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Word-wraps text to a width, keeping its own line breaks. A word
|
|
141
|
+
# longer than the width is cut.
|
|
142
|
+
#
|
|
143
|
+
# @param text [String]
|
|
144
|
+
# @param width [Integer]
|
|
145
|
+
# @return [String]
|
|
146
|
+
def wrap(text, width)
|
|
147
|
+
text.split("\n").flat_map { |line| line.scan(/\S.{0,#{width - 1}}(?=\s|\z)|\S{#{width}}/).map(&:strip) }.join("\n")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @param only [Array<String>, nil] case prefixes from --case
|
|
151
|
+
# @param dataset [String]
|
|
152
|
+
# @return [Array<Case>]
|
|
153
|
+
# @raise [Error] when nothing matches
|
|
154
|
+
def cases_for(only, dataset:)
|
|
155
|
+
list = discovery.cases(dataset: dataset, only: Array(only))
|
|
156
|
+
raise Error, "no cases match #{Array(only).join(", ")} in #{dataset}" if list.empty?
|
|
157
|
+
|
|
158
|
+
list
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module CLI
|
|
6
|
+
# Several models at one setting, side by side: one experiment each,
|
|
7
|
+
# with either a terminal leaderboard or a saved HTML/JSON report.
|
|
8
|
+
class Compare < ModelCommand
|
|
9
|
+
desc "Run every case against several models and report the results"
|
|
10
|
+
|
|
11
|
+
example [
|
|
12
|
+
" # everything .llm-evals.config.yml lists",
|
|
13
|
+
"-m anthropic/claude-haiku-5.5,openai/gpt-4o-mini -t 0",
|
|
14
|
+
"-e low -c clear -j 2 # two experiments at a time",
|
|
15
|
+
"-n # show the matrix, spend nothing"
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
def call(models:, dataset:, dry_run:, **options)
|
|
19
|
+
config = lineup(options)
|
|
20
|
+
weigh(config)
|
|
21
|
+
contenders = contenders(config, models, options)
|
|
22
|
+
datasets = datasets(dataset)
|
|
23
|
+
return matrix(datasets, contenders) if dry_run
|
|
24
|
+
|
|
25
|
+
setup!(contenders, options)
|
|
26
|
+
runs = datasets.flat_map { |name| contenders.map { experiment(it, config, options, dataset: name) } }
|
|
27
|
+
summaries = execute(runs, concurrency: concurrency(options))
|
|
28
|
+
report(summaries,
|
|
29
|
+
output: options[:output],
|
|
30
|
+
open: options[:open],
|
|
31
|
+
scores: options[:scores],
|
|
32
|
+
format: options[:format],
|
|
33
|
+
config:,
|
|
34
|
+
options:,
|
|
35
|
+
run_options: serialize_run_options(config:, contenders:, datasets:, runs:, models:, dataset:, options:))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module CLI
|
|
6
|
+
# How a command prints the numbers every report shares.
|
|
7
|
+
module Formatting
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def percent(value) = value.nil? ? "-" : format("%.1f%%", value * 100)
|
|
11
|
+
|
|
12
|
+
def dollars(value) = format("$%.4f", value.to_f)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module CLI
|
|
6
|
+
# The terminal summary of a run: one row per experiment, the glossary, the
|
|
7
|
+
# selected totals view, and the winners. Every column that is not a metric
|
|
8
|
+
# is the gem's; the metric columns, their glossary and the ranking come
|
|
9
|
+
# from the suite's {Scorer#metrics}.
|
|
10
|
+
class Leaderboard
|
|
11
|
+
include Formatting
|
|
12
|
+
|
|
13
|
+
# What the gem's own leaderboard columns mean, printed under it with the metrics'.
|
|
14
|
+
GLOSSARY = {
|
|
15
|
+
"effort" => "Reasoning effort sent to the model. \"default\" sends none, and the model decides; " \
|
|
16
|
+
"a provider that takes none shows \"default\" too.",
|
|
17
|
+
"temp" => "Sampling temperature sent to the model. \"default\" sends none.",
|
|
18
|
+
"errors" => "Cases whose call failed outright (an API or network error), so they have no scores at all. " \
|
|
19
|
+
"A reply the model got wrong is not an error; it scores 0.",
|
|
20
|
+
"time" => "Wall-clock time for the experiment. Experiments run side by side and share the network, " \
|
|
21
|
+
"so compare times within one run only.",
|
|
22
|
+
"tok_in" => "Input tokens sent, summed over the cases, as the provider counted them.",
|
|
23
|
+
"tok_out" => "Output tokens received, summed over the cases; reasoning tokens are included where the " \
|
|
24
|
+
"provider bills them as output.",
|
|
25
|
+
"cost" => "US dollars, as the provider reported them, summed over the cases.",
|
|
26
|
+
"score" => "The mean of each case's overall score, as the scorer computed it. With --scores, a table " \
|
|
27
|
+
"ranks every experiment by it."
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# @param standings [Standings]
|
|
31
|
+
# @param table [#call] draws rows, as {Command#table}
|
|
32
|
+
# @param out [IO] where the lines between tables go
|
|
33
|
+
# @param pastel [Pastel]
|
|
34
|
+
# @param colors [ModelColors] shared with the progress bars, so a model keeps its color
|
|
35
|
+
def initialize(standings:, table:, out:, pastel:, colors:)
|
|
36
|
+
@standings = standings
|
|
37
|
+
@table = table
|
|
38
|
+
@out = out
|
|
39
|
+
@pastel = pastel
|
|
40
|
+
@colors = colors
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# One row per experiment, best primary metric first, or best wscore when the
|
|
44
|
+
# file sets weights; the glossary; the selected totals view; with
|
|
45
|
+
# scores, every experiment by score; and, last, the winners.
|
|
46
|
+
#
|
|
47
|
+
# @param summaries [Array<Experiment::Summary>]
|
|
48
|
+
# @param scores [Boolean] also print every experiment by score
|
|
49
|
+
# @param totals_mode [String] `overall`, `variant` or `both`
|
|
50
|
+
# @return [void]
|
|
51
|
+
def call(summaries, scores: false, totals_mode: "overall")
|
|
52
|
+
weights = standings.weighted_scores(summaries)
|
|
53
|
+
ranked = summaries.sort_by { [it.dataset, -((weighted ? weights[it] : it.mean(primary)) || -1)] }
|
|
54
|
+
rows = ranked.map do |s|
|
|
55
|
+
[s.dataset, s.model.id, s.model.effort || "default", temperature_cell(s),
|
|
56
|
+
*(percent(weights[s]) if weighted),
|
|
57
|
+
*metrics.map { percent(s.mean(it)) },
|
|
58
|
+
s.errors.size, format("%.1fs", s.duration), s.usage.input_tokens, s.usage.output_tokens, dollars(s.usage.cost_usd)]
|
|
59
|
+
end
|
|
60
|
+
best = standings.best_per_dataset(ranked)
|
|
61
|
+
table.call(rows,
|
|
62
|
+
header: ["dataset", "model", "effort", "temp", *("wscore" if weighted), *metrics.map(&:name),
|
|
63
|
+
"errors", "time", "tok_in", "tok_out", "cost"],
|
|
64
|
+
highlight: best)
|
|
65
|
+
table.call(glossary, header: %w[Term Description], fill: 1, wrap: true)
|
|
66
|
+
overall = totals(summaries, mode: totals_mode)
|
|
67
|
+
score_table(summaries) if scores
|
|
68
|
+
winners("Best value: the cheapest within #{(Standings::VALUE_MARGIN * 100).round} points of the best #{primary.name}",
|
|
69
|
+
best.map { ranked[it] },
|
|
70
|
+
primary.name) { it.mean(primary) }
|
|
71
|
+
if weighted
|
|
72
|
+
leaders = ranked.group_by(&:dataset).values.filter_map { |group| group.find { weights[it] && it.errors.empty? } }
|
|
73
|
+
winners("Best wscore: by the weights in weighted_score", leaders, "wscore") { weights[it] }
|
|
74
|
+
end
|
|
75
|
+
overall&.call
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Array<Array(String, String)>] the metrics' descriptions, then the gem's columns, less any a metric took the name of
|
|
79
|
+
def glossary
|
|
80
|
+
described = metrics.map { [it.name, it.description.to_s.empty? ? "(no description)" : it.description] } + GLOSSARY.reject { |term, _| metrics.any? { it.name == term } }.to_a
|
|
81
|
+
weighted ? [["wscore", weighted.description], *described] : described
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
attr_reader :standings, :table, :out, :pastel, :colors
|
|
87
|
+
|
|
88
|
+
def metrics = standings.metrics
|
|
89
|
+
|
|
90
|
+
def primary = standings.primary
|
|
91
|
+
|
|
92
|
+
def weighted = standings.weighted
|
|
93
|
+
|
|
94
|
+
def value_line(label, value, cost)
|
|
95
|
+
out.puts "best value#{label}: #{format("%.1f%%", value * 100)} at #{dollars(cost)}, " \
|
|
96
|
+
"the cheapest within #{(Standings::VALUE_MARGIN * 100).round} points of the best #{primary.name}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def weighted_line(label, value)
|
|
100
|
+
out.puts "best wscore#{label}: #{format("%.1f%%", value * 100)}, by the weights in weighted_score"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def contender_label(summary) = "#{summary.model.id} effort=#{summary.model.effort || "default"}"
|
|
104
|
+
|
|
105
|
+
def temperature_cell(summary) = summary.model.temperature.nil? ? "default" : summary.model.temperature
|
|
106
|
+
|
|
107
|
+
# The selected totals view: one aggregate table, one per variant, or both.
|
|
108
|
+
#
|
|
109
|
+
# @return [Proc, nil] prints the conclusions over every dataset, for after
|
|
110
|
+
# everything else; nil over a single dataset
|
|
111
|
+
def totals(summaries, mode: "overall")
|
|
112
|
+
variants = summaries.flat_map { |summary| summary.results.map { it.case.variant } }.uniq.sort
|
|
113
|
+
several_datasets = summaries.map(&:dataset).uniq.size > 1
|
|
114
|
+
has_multiple_groups = several_datasets || variants.size > 1
|
|
115
|
+
if has_multiple_groups && %w[overall both].include?(mode)
|
|
116
|
+
out.puts "[all cases]"
|
|
117
|
+
contender_table(standings.contender_groups(summaries))
|
|
118
|
+
end
|
|
119
|
+
if has_multiple_groups && %w[variant both].include?(mode)
|
|
120
|
+
variants.each do |variant|
|
|
121
|
+
out.puts "[#{variant}]"
|
|
122
|
+
contender_table(standings.contender_groups(summaries.map { it.for_variant(variant, metrics) }))
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
return unless several_datasets
|
|
126
|
+
|
|
127
|
+
groups = standings.contender_groups(summaries)
|
|
128
|
+
best = standings.best_contender(groups)
|
|
129
|
+
winner = groups.find { it[:weighted] && it[:errors].zero? }
|
|
130
|
+
lambda do
|
|
131
|
+
value_line(" overall (#{contender_label(groups[best][:first])})", groups[best][:value], groups[best][:usage].cost_usd) if best
|
|
132
|
+
weighted_line(" overall (#{contender_label(winner[:first])})", winner[:weighted]) if weighted && winner
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @param groups [Array<Hash>] from {Standings#contender_groups}
|
|
137
|
+
# @return [void]
|
|
138
|
+
def contender_table(groups)
|
|
139
|
+
rows = groups.map do |g|
|
|
140
|
+
s = g[:first]
|
|
141
|
+
[s.model.id, s.model.effort || "default", temperature_cell(s), *(percent(g[:weighted]) if weighted), percent(g[:value]),
|
|
142
|
+
g[:errors], g[:usage].input_tokens, g[:usage].output_tokens, dollars(g[:usage].cost_usd)]
|
|
143
|
+
end
|
|
144
|
+
table.call(rows,
|
|
145
|
+
header: ["model", "effort", "temp", *("wscore" if weighted), primary.name, "errors", "tok_in", "tok_out", "cost"],
|
|
146
|
+
highlight: [standings.best_contender(groups)].compact,
|
|
147
|
+
fill: 0)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Every experiment by its mean score, best first, with its cost and its
|
|
151
|
+
# Braintrust page. URLs are never wrapped, so they stay clickable.
|
|
152
|
+
def score_table(summaries)
|
|
153
|
+
weights = standings.weighted_scores(summaries)
|
|
154
|
+
rows = summaries.sort_by { -(it.score || -1) }.map do |s|
|
|
155
|
+
[s.model.id, s.model.effort || "default", s.dataset, percent(s.score), *(percent(weights[s]) if weighted), dollars(s.usage.cost_usd), s.permalink || "-"]
|
|
156
|
+
end
|
|
157
|
+
table.call(rows, header: ["model", "effort", "dataset", "score", *("wscore" if weighted), "cost", "url"], fill: 0)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# One line per dataset's winner, each column aligned, with its model last in
|
|
161
|
+
# the color of its progress bars.
|
|
162
|
+
#
|
|
163
|
+
# @param title [String]
|
|
164
|
+
# @param summaries [Array<Experiment::Summary>]
|
|
165
|
+
# @param label [String] the name of what the block reads
|
|
166
|
+
# @yieldparam summary [Experiment::Summary]
|
|
167
|
+
# @yieldreturn [Float]
|
|
168
|
+
def winners(title, summaries, label)
|
|
169
|
+
return if summaries.empty?
|
|
170
|
+
|
|
171
|
+
rows = summaries.map { ["[#{it.dataset}]", "effort=#{it.model.effort || "default"}", "#{label}=#{percent(yield(it))}", dollars(it.usage.cost_usd)] }
|
|
172
|
+
widths = rows.transpose.map { |column| column.map(&:length).max }
|
|
173
|
+
out.puts title
|
|
174
|
+
rows.zip(summaries).each do |(dataset, effort, value, cost), summary|
|
|
175
|
+
model = summary.model.id
|
|
176
|
+
out.puts " #{dataset.ljust(widths[0])} #{effort.ljust(widths[1])} #{value.rjust(widths[2])} #{cost.rjust(widths[3])} " \
|
|
177
|
+
"#{pastel.public_send(colors[model], model)}"
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module CLI
|
|
6
|
+
# Each model's bright color for one run, chosen at random and kept, so a
|
|
7
|
+
# model looks the same in its progress bars and in the tables after them.
|
|
8
|
+
class ModelColors
|
|
9
|
+
# What a model can be drawn in. More models than these share them.
|
|
10
|
+
COLORS = %i[bright_red bright_green bright_yellow bright_blue bright_magenta bright_cyan bright_white].freeze
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
palette = COLORS.shuffle
|
|
14
|
+
@colors = Hash.new { |colors, model| colors[model] = palette[colors.size % palette.size] }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @param model [String] the model's id
|
|
18
|
+
# @return [Symbol] a Pastel style
|
|
19
|
+
def [](model) = @colors[model]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|