llm-evals 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +26 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +764 -0
  5. data/exe/evals +9 -0
  6. data/exe/llm-evals +14 -0
  7. data/lib/llm/evals/adapter.rb +47 -0
  8. data/lib/llm/evals/case.rb +36 -0
  9. data/lib/llm/evals/cli/cases.rb +48 -0
  10. data/lib/llm/evals/cli/command.rb +163 -0
  11. data/lib/llm/evals/cli/compare.rb +40 -0
  12. data/lib/llm/evals/cli/formatting.rb +16 -0
  13. data/lib/llm/evals/cli/leaderboard.rb +183 -0
  14. data/lib/llm/evals/cli/model_colors.rb +23 -0
  15. data/lib/llm/evals/cli/model_command.rb +437 -0
  16. data/lib/llm/evals/cli/progress_bars.rb +147 -0
  17. data/lib/llm/evals/cli/render.rb +37 -0
  18. data/lib/llm/evals/cli/run.rb +69 -0
  19. data/lib/llm/evals/cli/standings.rb +114 -0
  20. data/lib/llm/evals/cli/verify.rb +24 -0
  21. data/lib/llm/evals/cli/version.rb +15 -0
  22. data/lib/llm/evals/cli.rb +43 -0
  23. data/lib/llm/evals/completion.rb +11 -0
  24. data/lib/llm/evals/config.rb +198 -0
  25. data/lib/llm/evals/configuration.rb +173 -0
  26. data/lib/llm/evals/contender.rb +31 -0
  27. data/lib/llm/evals/discovery.rb +95 -0
  28. data/lib/llm/evals/experiment.rb +158 -0
  29. data/lib/llm/evals/helper.rb +98 -0
  30. data/lib/llm/evals/launcher.rb +37 -0
  31. data/lib/llm/evals/metric.rb +26 -0
  32. data/lib/llm/evals/model.rb +55 -0
  33. data/lib/llm/evals/providers/base.rb +93 -0
  34. data/lib/llm/evals/providers/catalog.rb +32 -0
  35. data/lib/llm/evals/providers/open_router.rb +64 -0
  36. data/lib/llm/evals/providers/registry.rb +112 -0
  37. data/lib/llm/evals/providers/type_safe.rb +53 -0
  38. data/lib/llm/evals/render/reports/html.rb +266 -0
  39. data/lib/llm/evals/render/reports/json.rb +172 -0
  40. data/lib/llm/evals/render/reports/pipeline.rb +153 -0
  41. data/lib/llm/evals/render/reports/terminal.rb +46 -0
  42. data/lib/llm/evals/reply.rb +18 -0
  43. data/lib/llm/evals/run_result.rb +14 -0
  44. data/lib/llm/evals/runtime.rb +74 -0
  45. data/lib/llm/evals/schema_validator.rb +84 -0
  46. data/lib/llm/evals/score.rb +26 -0
  47. data/lib/llm/evals/scorer.rb +45 -0
  48. data/lib/llm/evals/templates/html_report.css +206 -0
  49. data/lib/llm/evals/templates/html_report.html.erb +184 -0
  50. data/lib/llm/evals/templates/html_report.js +48 -0
  51. data/lib/llm/evals/testing/echo_adapter.rb +29 -0
  52. data/lib/llm/evals/testing/fake_model.rb +18 -0
  53. data/lib/llm/evals/testing/fake_provider.rb +67 -0
  54. data/lib/llm/evals/tracker.rb +80 -0
  55. data/lib/llm/evals/trackers/braintrust.rb +91 -0
  56. data/lib/llm/evals/trackers/local.rb +78 -0
  57. data/lib/llm/evals/trackers/logfire.rb +143 -0
  58. data/lib/llm/evals/trackers/registry.rb +82 -0
  59. data/lib/llm/evals/usage.rb +33 -0
  60. data/lib/llm/evals/verify.rb +49 -0
  61. data/lib/llm/evals/version.rb +7 -0
  62. data/lib/llm/evals/warnings.rb +98 -0
  63. data/lib/llm/evals/weighted_score.rb +116 -0
  64. data/lib/llm/evals.rb +83 -0
  65. metadata +274 -0
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