llm-evals 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +163 -87
  4. data/exe/llm-evals +60 -5
  5. data/lib/.DS_Store +0 -0
  6. data/lib/llm/.DS_Store +0 -0
  7. data/lib/llm/evals/actions/adapter.rb +50 -0
  8. data/lib/llm/evals/actions/error_budget.rb +63 -0
  9. data/lib/llm/evals/actions/error_diagnosis.rb +51 -0
  10. data/lib/llm/evals/actions/error_streak.rb +83 -0
  11. data/lib/llm/evals/actions/experiment.rb +251 -0
  12. data/lib/llm/evals/actions/verify.rb +61 -0
  13. data/lib/llm/evals/actions.rb +9 -0
  14. data/lib/llm/evals/cli/cases.rb +10 -0
  15. data/lib/llm/evals/cli/command.rb +32 -99
  16. data/lib/llm/evals/cli/compare.rb +0 -20
  17. data/lib/llm/evals/cli/contender_selector.rb +61 -0
  18. data/lib/llm/evals/cli/error_pause.rb +81 -0
  19. data/lib/llm/evals/cli/experiment_pool.rb +97 -0
  20. data/lib/llm/evals/cli/formatting.rb +8 -0
  21. data/lib/llm/evals/cli/leaderboard.rb +66 -12
  22. data/lib/llm/evals/cli/model_colors.rb +3 -0
  23. data/lib/llm/evals/cli/model_command.rb +152 -315
  24. data/lib/llm/evals/cli/model_options.rb +80 -0
  25. data/lib/llm/evals/cli/option_groups.rb +32 -0
  26. data/lib/llm/evals/cli/progress_bars.rb +92 -5
  27. data/lib/llm/evals/cli/render.rb +6 -6
  28. data/lib/llm/evals/cli/report_dispatcher/console.rb +65 -0
  29. data/lib/llm/evals/cli/report_dispatcher/html_output.rb +50 -0
  30. data/lib/llm/evals/cli/report_dispatcher/json_output.rb +29 -0
  31. data/lib/llm/evals/cli/report_dispatcher/output.rb +56 -0
  32. data/lib/llm/evals/cli/report_dispatcher/pipeline_output.rb +38 -0
  33. data/lib/llm/evals/cli/report_dispatcher.rb +133 -0
  34. data/lib/llm/evals/cli/run.rb +13 -22
  35. data/lib/llm/evals/cli/run_planner.rb +97 -0
  36. data/lib/llm/evals/cli/run_record.rb +68 -0
  37. data/lib/llm/evals/cli/standings.rb +39 -17
  38. data/lib/llm/evals/cli/status_box.rb +51 -0
  39. data/lib/llm/evals/cli/table.rb +103 -0
  40. data/lib/llm/evals/cli/text.rb +22 -0
  41. data/lib/llm/evals/cli/tracker_resolver.rb +52 -0
  42. data/lib/llm/evals/cli/upgrade_config.rb +60 -0
  43. data/lib/llm/evals/cli/verify.rb +5 -1
  44. data/lib/llm/evals/cli/version.rb +4 -0
  45. data/lib/llm/evals/cli/weights.rb +48 -0
  46. data/lib/llm/evals/cli.rb +39 -6
  47. data/lib/llm/evals/config/contender_builder.rb +30 -0
  48. data/lib/llm/evals/config/error_limits.rb +41 -0
  49. data/lib/llm/evals/config/listing.rb +153 -0
  50. data/lib/llm/evals/config/results.rb +42 -0
  51. data/lib/llm/evals/config/score_weights.rb +25 -0
  52. data/lib/llm/evals/config/upgrade.rb +107 -0
  53. data/lib/llm/evals/config.rb +90 -79
  54. data/lib/llm/evals/datasets/case.rb +46 -0
  55. data/lib/llm/evals/datasets/discovery.rb +143 -0
  56. data/lib/llm/evals/datasets.rb +9 -0
  57. data/lib/llm/evals/launcher.rb +57 -3
  58. data/lib/llm/evals/models/contender.rb +51 -0
  59. data/lib/llm/evals/models/model.rb +87 -0
  60. data/lib/llm/evals/models/throttle.rb +170 -0
  61. data/lib/llm/evals/models.rb +9 -0
  62. data/lib/llm/evals/providers/base.rb +61 -16
  63. data/lib/llm/evals/providers/catalog.rb +5 -2
  64. data/lib/llm/evals/providers/open_router.rb +54 -7
  65. data/lib/llm/evals/providers/registry.rb +49 -16
  66. data/lib/llm/evals/providers/type_safe/client.rb +67 -0
  67. data/lib/llm/evals/providers/type_safe.rb +113 -12
  68. data/lib/llm/evals/providers.rb +9 -0
  69. data/lib/llm/evals/render/reports/call_stats_table.rb +44 -0
  70. data/lib/llm/evals/render/reports/html/dataset_section.rb +51 -0
  71. data/lib/llm/evals/render/reports/html/format.rb +38 -0
  72. data/lib/llm/evals/render/reports/html/global_standings.rb +41 -0
  73. data/lib/llm/evals/render/reports/html/ranking.rb +86 -0
  74. data/lib/llm/evals/render/reports/html/view_model.rb +116 -0
  75. data/lib/llm/evals/render/reports/html.rb +60 -181
  76. data/lib/llm/evals/render/reports/json/decoder.rb +137 -0
  77. data/lib/llm/evals/render/reports/json/encoder.rb +106 -0
  78. data/lib/llm/evals/render/reports/json.rb +38 -141
  79. data/lib/llm/evals/render/reports/pipeline.rb +55 -20
  80. data/lib/llm/evals/render/reports/terminal.rb +8 -1
  81. data/lib/llm/evals/render.rb +9 -0
  82. data/lib/llm/evals/results/provider_response.rb +25 -0
  83. data/lib/llm/evals/results/reply.rb +38 -0
  84. data/lib/llm/evals/results/run_outcome.rb +15 -0
  85. data/lib/llm/evals/results/run_result.rb +40 -0
  86. data/lib/llm/evals/results/score.rb +45 -0
  87. data/lib/llm/evals/results/warnings.rb +149 -0
  88. data/lib/llm/evals/results.rb +9 -0
  89. data/lib/llm/evals/runtime/configuration.rb +295 -0
  90. data/lib/llm/evals/runtime/environment.rb +90 -0
  91. data/lib/llm/evals/runtime/helper.rb +153 -0
  92. data/lib/llm/evals/runtime.rb +2 -67
  93. data/lib/llm/evals/scoring/metric.rb +47 -0
  94. data/lib/llm/evals/scoring/schema_validator.rb +102 -0
  95. data/lib/llm/evals/scoring/scorer.rb +49 -0
  96. data/lib/llm/evals/scoring/weighted_score.rb +148 -0
  97. data/lib/llm/evals/scoring.rb +9 -0
  98. data/lib/llm/evals/stats/call_stats.rb +91 -0
  99. data/lib/llm/evals/stats/usage.rb +46 -0
  100. data/lib/llm/evals/stats.rb +29 -0
  101. data/lib/llm/evals/testing/echo_adapter.rb +14 -9
  102. data/lib/llm/evals/testing/fake_model.rb +8 -6
  103. data/lib/llm/evals/testing/fake_provider.rb +39 -8
  104. data/lib/llm/evals/testing.rb +9 -0
  105. data/lib/llm/evals/trackers/base.rb +110 -0
  106. data/lib/llm/evals/trackers/braintrust.rb +40 -3
  107. data/lib/llm/evals/trackers/local.rb +38 -2
  108. data/lib/llm/evals/trackers/logfire.rb +56 -3
  109. data/lib/llm/evals/trackers/registry.rb +29 -7
  110. data/lib/llm/evals/trackers.rb +9 -0
  111. data/lib/llm/evals/version.rb +3 -1
  112. data/lib/llm/evals.rb +17 -7
  113. metadata +91 -24
  114. data/lib/llm/evals/adapter.rb +0 -47
  115. data/lib/llm/evals/case.rb +0 -36
  116. data/lib/llm/evals/completion.rb +0 -11
  117. data/lib/llm/evals/configuration.rb +0 -173
  118. data/lib/llm/evals/contender.rb +0 -31
  119. data/lib/llm/evals/discovery.rb +0 -95
  120. data/lib/llm/evals/experiment.rb +0 -158
  121. data/lib/llm/evals/helper.rb +0 -98
  122. data/lib/llm/evals/metric.rb +0 -26
  123. data/lib/llm/evals/model.rb +0 -55
  124. data/lib/llm/evals/reply.rb +0 -18
  125. data/lib/llm/evals/run_result.rb +0 -14
  126. data/lib/llm/evals/schema_validator.rb +0 -84
  127. data/lib/llm/evals/score.rb +0 -26
  128. data/lib/llm/evals/scorer.rb +0 -45
  129. data/lib/llm/evals/tracker.rb +0 -80
  130. data/lib/llm/evals/usage.rb +0 -33
  131. data/lib/llm/evals/verify.rb +0 -49
  132. data/lib/llm/evals/warnings.rb +0 -98
  133. data/lib/llm/evals/weighted_score.rb +0 -116
  134. /data/lib/llm/evals/{templates → render/templates}/html_report.css +0 -0
  135. /data/lib/llm/evals/{templates → render/templates}/html_report.html.erb +0 -0
  136. /data/lib/llm/evals/{templates → render/templates}/html_report.js +0 -0
@@ -2,8 +2,7 @@
2
2
 
3
3
  require "io/console"
4
4
  require "pastel"
5
- require "tty-box"
6
- require "tty-table"
5
+ require "pathname"
7
6
 
8
7
  module LLM
9
8
  module Evals
@@ -12,27 +11,13 @@ module LLM
12
11
  class Command < Dry::CLI::Command
13
12
  include Dry::CLI::UI
14
13
 
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
14
  # The width assumed when the output is not a terminal.
23
15
  DEFAULT_WIDTH = 120
24
16
 
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
-
17
+ # Gives each subclass --verbose, since dry-cli starts every subclass with no options.
18
+ #
19
+ # @param subclass [Class<Command>]
20
+ # @return [void]
36
21
  def self.inherited(subclass)
37
22
  super
38
23
  subclass.option :verbose, type: :boolean, default: false, aliases: ["-v"], desc: "Print the full backtrace on failure"
@@ -40,81 +25,34 @@ module LLM
40
25
 
41
26
  private
42
27
 
43
- # @return [Configuration] the suite, which must be loaded
28
+ # Fails here rather than deep inside a command when no helper set a suite up.
29
+ #
30
+ # @return [Runtime::Configuration] the suite, which must be loaded
44
31
  def configuration = LLM::Evals.configuration.require_suite!
45
32
 
46
- # @return [Discovery]
33
+ # Where the suite's cases are found.
34
+ #
35
+ # @return [Datasets::Discovery]
47
36
  def discovery = configuration.discovery
48
37
 
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.
38
+ # Prints nothing at all for no rows, not even the header.
57
39
  #
40
+ # @see Table#render
58
41
  # @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
42
  # @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
43
+ def table(rows, **)
44
+ out.puts Table.new(pastel:, width: terminal_width).render(rows, **) unless rows.empty?
91
45
  end
92
46
 
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.
47
+ # Prints one box as wide as the terminal.
102
48
  #
103
- # @param level [Symbol] a key of {STATUS}
49
+ # @see StatusBox#render
50
+ # @param level [Symbol] a key of {StatusBox::LEVELS}
104
51
  # @param text [String]
105
52
  # @param stream [IO] out by default; warnings and errors go to err
106
53
  # @return [void]
107
54
  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)
55
+ stream.puts StatusBox.new(width: terminal_width, color: color?(stream)).render(level, text)
118
56
  end
119
57
 
120
58
  # A report written with -o is in color, as dry-cli-ui writes its own.
@@ -125,8 +63,13 @@ module LLM
125
63
  (stream.respond_to?(:color?) && stream.color?) || (stream.respond_to?(:tty?) && stream.tty? && !ENV.key?("NO_COLOR"))
126
64
  end
127
65
 
66
+ # Follows {#color?} for the current stream; {ModelCommand#writing_to} clears it when that changes.
67
+ #
68
+ # @return [Pastel]
128
69
  def pastel = @pastel ||= Pastel.new(enabled: color?)
129
70
 
71
+ # A terminal that cannot report its size counts as not being one.
72
+ #
130
73
  # @return [Integer] columns available on this terminal; {DEFAULT_WIDTH} when it is not one.
131
74
  # While writing to a report, the screen's.
132
75
  def terminal_width
@@ -137,26 +80,16 @@ module LLM
137
80
  DEFAULT_WIDTH
138
81
  end
139
82
 
140
- # Word-wraps text to a width, keeping its own line breaks. A word
141
- # longer than the width is cut.
83
+ # Memoized for the life of the command.
142
84
  #
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?
85
+ # @return [Dry::CLI::UI::Invocation] how the command was run, which names its files
86
+ def invocation = @invocation ||= Dry::CLI::UI::Invocation.for(self)
157
87
 
158
- list
159
- end
88
+ # Shortens a path for a message to the user.
89
+ #
90
+ # @param path [String]
91
+ # @return [String] relative to the working directory
92
+ def relative(path) = Pathname(path).relative_path_from(Dir.pwd).to_s
160
93
  end
161
94
  end
162
95
  end
@@ -14,26 +14,6 @@ module LLM
14
14
  "-e low -c clear -j 2 # two experiments at a time",
15
15
  "-n # show the matrix, spend nothing"
16
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
17
  end
38
18
  end
39
19
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module CLI
6
+ # Who runs: the models named on the command line, or every model the lineup
7
+ # lists, less those whose provider cannot produce what the suite declares.
8
+ class ContenderSelector
9
+ # Holds only collaborators; nothing is resolved until {#call}.
10
+ #
11
+ # @param config [Config]
12
+ # @param providers [Providers::Registry]
13
+ # @param warnings [Results::Warnings] records each refusal
14
+ def initialize(config:, providers:, warnings:)
15
+ @config = config
16
+ @providers = providers
17
+ @warnings = warnings
18
+ end
19
+
20
+ # Settles the lineup before anything is spent on it.
21
+ #
22
+ # @param models [Array<String>] as given to -m, possibly comma-separated
23
+ # @param options [Hash] the parsed flags, whose settings override the lineup's
24
+ # @return [Array<Models::Contender>]
25
+ # @raise [Error] when nothing names a model, or none is admitted
26
+ def call(models, options)
27
+ named = models.flat_map { it.split(",") }
28
+ settings = overrides(options)
29
+ found = named.empty? ? @config.contenders(settings) : @config.contenders_for(named, settings)
30
+ raise Error, "no models: list some under a provider in #{@config.path}, or pass -m" if found.empty?
31
+
32
+ admit(found)
33
+ end
34
+
35
+ private
36
+
37
+ # Spelled as the lineup spells them. The lineup ignores a nil, so an absent flag changes nothing.
38
+ #
39
+ # @param options [Hash] the parsed flags
40
+ # @return [Hash{String => Object}] the setting flags that were given
41
+ def overrides(options)
42
+ { "effort" => options[:effort], "temperature" => options[:temperature], "max_tokens" => options[:max_tokens] }
43
+ end
44
+
45
+ # A refused model is reported rather than run, failed and billed.
46
+ #
47
+ # @param found [Array<Models::Contender>]
48
+ # @return [Array<Models::Contender>] those the suite can ask
49
+ # @raise [Error] when no provider serves one of them, or none is admitted
50
+ def admit(found)
51
+ found.each { @providers.for(it) } # an unserved id fails here, before anything is spent
52
+ admitted, refusals = @providers.admit(found, request_types: @config.request_types)
53
+ refusals.each { @warnings.record(it.to_s) }
54
+ raise Error, "no admitted models: #{refusals.join("; ")}" if admitted.empty?
55
+
56
+ admitted
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module CLI
6
+ # Says what is failing and asks whether to carry on. With nobody to ask, as
7
+ # in CI, it stops: nothing suggests the remaining calls would fare better.
8
+ class ErrorPause
9
+ # The question put to the user.
10
+ QUESTION = "Keep running?"
11
+
12
+ # The most error messages shown; the rest are only counted.
13
+ MESSAGES = 3
14
+
15
+ # The most names listed per dimension; the rest are only counted.
16
+ NAMES = 5
17
+
18
+ # How much of one message is shown.
19
+ MESSAGE_WIDTH = 300
20
+
21
+ # @param console [Dry::CLI::UI::Console] asks the question
22
+ # @param notice [#call] given the box's level and text
23
+ # @param runs [Array<Actions::Experiment>] the whole run
24
+ # @param yes [Boolean] carry on without asking
25
+ # @param interactive [Boolean] whether anyone can answer
26
+ def initialize(console:, notice:, runs:, yes: false, interactive: $stdin.tty?)
27
+ @console = console
28
+ @notice = notice
29
+ @runs = runs
30
+ @yes = yes
31
+ @interactive = interactive
32
+ end
33
+
34
+ # @param failures [Array<Results::RunResult>] the streak
35
+ # @return [Boolean] whether the run should carry on
36
+ def call(failures)
37
+ @notice.call(:error, describe(Actions::ErrorDiagnosis.new(failures:, runs: @runs), failures.size))
38
+ return true if @yes
39
+ return false unless @interactive
40
+
41
+ @console.confirm(QUESTION, default: false)
42
+ end
43
+
44
+ private
45
+
46
+ # @param diagnosis [Actions::ErrorDiagnosis]
47
+ # @param count [Integer] the failures in the streak
48
+ # @return [String] the box's text
49
+ def describe(diagnosis, count)
50
+ scopes = { "Models" => diagnosis.models, "Datasets" => diagnosis.datasets, "Cases" => diagnosis.cases }
51
+ lines = ["#{count} calls in a row failed. Paused: no new calls will start.", *errors(diagnosis.messages)]
52
+ lines.concat(scopes.map { |name, scope| "#{name}: #{extent(scope)}" })
53
+ lines << "The others have not failed so far, or have not run yet." unless scopes.values.all?(&:all?)
54
+ lines.join("\n")
55
+ end
56
+
57
+ # @param messages [Array<Array(String, Integer)>]
58
+ # @return [Array<String>]
59
+ def errors(messages)
60
+ shown = messages.first(MESSAGES).map { |message, count| "#{count} x #{message[0, MESSAGE_WIDTH]}" }
61
+ hidden = messages.size - shown.size
62
+ if hidden.positive?
63
+ [*shown, "and #{hidden} other #{hidden == 1 ? "error" : "errors"}"]
64
+ else
65
+ shown
66
+ end
67
+ end
68
+
69
+ # @param scope [Actions::ErrorDiagnosis::Scope]
70
+ # @return [String] "all 3 affected", or how many and which
71
+ def extent(scope)
72
+ return "all #{scope.total} affected" if scope.all?
73
+
74
+ hidden = scope.failed.size - NAMES
75
+ names = scope.failed.first(NAMES).join(", ")
76
+ "#{scope.failed.size} of #{scope.total} affected: #{names}#{" and #{hidden} more" if hidden.positive?}"
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module CLI
6
+ # Runs the experiments side by side, a progress bar each. What would print
7
+ # as a warning meanwhile, failed cases included, goes to the warnings instead.
8
+ class ExperimentPool
9
+ # What can halt a run: the lineup's budget, which ends it, and the streak,
10
+ # which pauses it until the pause says otherwise. Either may be absent.
11
+ #
12
+ # @!attribute [r] budget
13
+ # @return [Actions::ErrorBudget, nil]
14
+ # @!attribute [r] streak
15
+ # @return [Actions::ErrorStreak, nil]
16
+ # @!attribute [r] pause
17
+ # @return [#call, nil] given the streak; answers whether to carry on
18
+ Halts = Data.define(:budget, :streak, :pause) do
19
+ # Waits out a pause first, so a worker acts on the answer.
20
+ #
21
+ # @return [Boolean] whether no new case should start
22
+ def stop?
23
+ streak&.hold
24
+ budget&.spent? || streak&.stopped? || false
25
+ end
26
+
27
+ # Counts a finished call. The bars stay still while the question is up.
28
+ #
29
+ # @param result [Results::RunResult]
30
+ # @param widget [ProgressBars]
31
+ # @return [void]
32
+ def record(result, widget)
33
+ budget&.record(result)
34
+ streak&.record(result) { |failures| widget.pausing { pause.call(failures) } }
35
+ end
36
+ end
37
+
38
+ # Holds only collaborators; nothing runs until {#call}.
39
+ #
40
+ # @param console [Dry::CLI::UI::Console]
41
+ # @param colors [ModelColors]
42
+ # @param warnings [Results::Warnings]
43
+ def initialize(console:, colors:, warnings:)
44
+ @console = console
45
+ @colors = colors
46
+ @warnings = warnings
47
+ end
48
+
49
+ # Returns once every experiment has ended; one the budget stopped before it began has no summary.
50
+ #
51
+ # @param runs [Array<Actions::Experiment>]
52
+ # @param concurrency [Integer] experiments at once; 0 runs them all together
53
+ # @param budget [Actions::ErrorBudget, nil] stops the run once spent
54
+ # @param streak [Actions::ErrorStreak, nil] pauses the run on a streak of failures
55
+ # @param pause [#call, nil] given the streak; answers whether to carry on
56
+ # @return [Results::RunOutcome]
57
+ def call(runs, concurrency:, budget: nil, streak: nil, pause: nil)
58
+ summaries = Array.new(runs.size)
59
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
60
+ @warnings.capture { bars(runs, summaries, concurrency, Halts.new(budget:, streak:, pause:)) }
61
+ seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
62
+ summaries.compact.each { |s| s.errors.each { @warnings.record("[#{s.name}] #{it}") } }
63
+ Results::RunOutcome.new(summaries: summaries.compact, seconds:)
64
+ end
65
+
66
+ private
67
+
68
+ # Draws a bar per run and fills summaries by position, so they keep the order
69
+ # given whichever finishes first. A run the budget stopped before it began leaves a nil.
70
+ #
71
+ # @param runs [Array<Actions::Experiment>]
72
+ # @param summaries [Array<Actions::Experiment::Summary, nil>] written in place, one slot per run
73
+ # @param concurrency [Integer] experiments at once; 0 runs them all together
74
+ # @param halts [Halts]
75
+ # @return [void]
76
+ def bars(runs, summaries, concurrency, halts)
77
+ widget = ProgressBars.for(@console, colors: @colors)
78
+ widget.run("Running #{runs.size} experiment#{"s" unless runs.size == 1}",
79
+ concurrent: concurrency.zero? || concurrency) do |multi|
80
+ labels = ProgressBars.labels(runs)
81
+ runs.each_with_index do |run, index|
82
+ multi.progress(labels[index], total: run.cases.size) do |bar|
83
+ next if halts.stop?
84
+
85
+ summaries[index] = run.run(stop: -> { halts.stop? },
86
+ on_case: lambda { |result|
87
+ bar.advance(as: result.errored? ? :failed : :ok)
88
+ halts.record(result, widget)
89
+ })
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -7,8 +7,16 @@ module LLM
7
7
  module Formatting
8
8
  private
9
9
 
10
+ # A missing number prints as a dash, never as 0%, which would read as a score.
11
+ #
12
+ # @param value [Float, nil] a fraction, where 1.0 is 100%
13
+ # @return [String]
10
14
  def percent(value) = value.nil? ? "-" : format("%.1f%%", value * 100)
11
15
 
16
+ # Four decimals, since a single cheap experiment can cost a fraction of a cent.
17
+ #
18
+ # @param value [Numeric]
19
+ # @return [String]
12
20
  def dollars(value) = format("$%.4f", value.to_f)
13
21
  end
14
22
  end
@@ -6,7 +6,7 @@ module LLM
6
6
  # The terminal summary of a run: one row per experiment, the glossary, the
7
7
  # selected totals view, and the winners. Every column that is not a metric
8
8
  # is the gem's; the metric columns, their glossary and the ranking come
9
- # from the suite's {Scorer#metrics}.
9
+ # from the suite's {Scoring::Scorer#metrics}.
10
10
  class Leaderboard
11
11
  include Formatting
12
12
 
@@ -27,9 +27,11 @@ module LLM
27
27
  "ranks every experiment by it."
28
28
  }.freeze
29
29
 
30
+ # Built per call by {ModelCommand#leaderboard}, since the metrics and the stream can change between calls.
31
+ #
30
32
  # @param standings [Standings]
31
33
  # @param table [#call] draws rows, as {Command#table}
32
- # @param out [IO] where the lines between tables go
34
+ # @param out [IO, Dry::CLI::UI::Report] where the lines between tables go
33
35
  # @param pastel [Pastel]
34
36
  # @param colors [ModelColors] shared with the progress bars, so a model keeps its color
35
37
  def initialize(standings:, table:, out:, pastel:, colors:)
@@ -44,7 +46,7 @@ module LLM
44
46
  # file sets weights; the glossary; the selected totals view; with
45
47
  # scores, every experiment by score; and, last, the winners.
46
48
  #
47
- # @param summaries [Array<Experiment::Summary>]
49
+ # @param summaries [Array<Actions::Experiment::Summary>]
48
50
  # @param scores [Boolean] also print every experiment by score
49
51
  # @param totals_mode [String] `overall`, `variant` or `both`
50
52
  # @return [void]
@@ -52,7 +54,7 @@ module LLM
52
54
  weights = standings.weighted_scores(summaries)
53
55
  ranked = summaries.sort_by { [it.dataset, -((weighted ? weights[it] : it.mean(primary)) || -1)] }
54
56
  rows = ranked.map do |s|
55
- [s.dataset, s.model.id, s.model.effort || "default", temperature_cell(s),
57
+ [s.dataset, s.model.id, s.model.effort_label, s.model.temperature_label,
56
58
  *(percent(weights[s]) if weighted),
57
59
  *metrics.map { percent(s.mean(it)) },
58
60
  s.errors.size, format("%.1fs", s.duration), s.usage.input_tokens, s.usage.output_tokens, dollars(s.usage.cost_usd)]
@@ -75,6 +77,8 @@ module LLM
75
77
  overall&.call
76
78
  end
77
79
 
80
+ # A metric named like one of the gem's columns replaces its entry, and wscore leads when the file sets weights.
81
+ #
78
82
  # @return [Array<Array(String, String)>] the metrics' descriptions, then the gem's columns, less any a metric took the name of
79
83
  def glossary
80
84
  described = metrics.map { [it.name, it.description.to_s.empty? ? "(no description)" : it.description] } + GLOSSARY.reject { |term, _| metrics.any? { it.name == term } }.to_a
@@ -85,27 +89,71 @@ module LLM
85
89
 
86
90
  attr_reader :standings, :table, :out, :pastel, :colors
87
91
 
92
+ # @!attribute [r] standings
93
+ # The arithmetic behind every ranking here.
94
+ # @return [Standings]
95
+
96
+ # @!attribute [r] table
97
+ # Draws rows through the command, so they follow its stream.
98
+ # @return [#call]
99
+
100
+ # @!attribute [r] out
101
+ # Where the lines between tables go.
102
+ # @return [IO, Dry::CLI::UI::Report]
103
+
104
+ # @!attribute [r] pastel
105
+ # Paints the winners' models.
106
+ # @return [Pastel]
107
+
108
+ # @!attribute [r] colors
109
+ # Shared with the progress bars.
110
+ # @return [ModelColors]
111
+
112
+ # The columns the suite scores.
113
+ #
114
+ # @return [Array<Scoring::Metric>]
88
115
  def metrics = standings.metrics
89
116
 
117
+ # The metric the ranking reads.
118
+ #
119
+ # @return [Scoring::Metric]
90
120
  def primary = standings.primary
91
121
 
122
+ # Doubles as the question of whether the file sets weights at all.
123
+ #
124
+ # @return [Scoring::WeightedScore, nil] nil when it does not
92
125
  def weighted = standings.weighted
93
126
 
127
+ # Prints the best value over every dataset.
128
+ #
129
+ # @param label [String] what follows "best value", with its leading space
130
+ # @param value [Float] the primary metric, as a fraction
131
+ # @param cost [Float] in US dollars
132
+ # @return [void]
94
133
  def value_line(label, value, cost)
95
134
  out.puts "best value#{label}: #{format("%.1f%%", value * 100)} at #{dollars(cost)}, " \
96
135
  "the cheapest within #{(Standings::VALUE_MARGIN * 100).round} points of the best #{primary.name}"
97
136
  end
98
137
 
138
+ # Prints the best weighted score over every dataset.
139
+ #
140
+ # @param label [String] what follows "best wscore", with its leading space
141
+ # @param value [Float] the weighted score, as a fraction
142
+ # @return [void]
99
143
  def weighted_line(label, value)
100
144
  out.puts "best wscore#{label}: #{format("%.1f%%", value * 100)}, by the weights in weighted_score"
101
145
  end
102
146
 
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
147
+ # Names a contender in the conclusions over every dataset, by model and effort.
148
+ #
149
+ # @param summary [Actions::Experiment::Summary] any of the contender's experiments
150
+ # @return [String]
151
+ def contender_label(summary) = "#{summary.model.id} effort=#{summary.model.effort_label}"
106
152
 
107
153
  # The selected totals view: one aggregate table, one per variant, or both.
108
154
  #
155
+ # @param summaries [Array<Actions::Experiment::Summary>]
156
+ # @param mode [String] `overall`, `variant` or `both`
109
157
  # @return [Proc, nil] prints the conclusions over every dataset, for after
110
158
  # everything else; nil over a single dataset
111
159
  def totals(summaries, mode: "overall")
@@ -133,12 +181,14 @@ module LLM
133
181
  end
134
182
  end
135
183
 
184
+ # One row per contender, with the best value highlighted.
185
+ #
136
186
  # @param groups [Array<Hash>] from {Standings#contender_groups}
137
187
  # @return [void]
138
188
  def contender_table(groups)
139
189
  rows = groups.map do |g|
140
190
  s = g[:first]
141
- [s.model.id, s.model.effort || "default", temperature_cell(s), *(percent(g[:weighted]) if weighted), percent(g[:value]),
191
+ [s.model.id, s.model.effort_label, s.model.temperature_label, *(percent(g[:weighted]) if weighted), percent(g[:value]),
142
192
  g[:errors], g[:usage].input_tokens, g[:usage].output_tokens, dollars(g[:usage].cost_usd)]
143
193
  end
144
194
  table.call(rows,
@@ -149,10 +199,13 @@ module LLM
149
199
 
150
200
  # Every experiment by its mean score, best first, with its cost and its
151
201
  # Braintrust page. URLs are never wrapped, so they stay clickable.
202
+ #
203
+ # @param summaries [Array<Actions::Experiment::Summary>]
204
+ # @return [void]
152
205
  def score_table(summaries)
153
206
  weights = standings.weighted_scores(summaries)
154
207
  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 || "-"]
208
+ [s.model.id, s.model.effort_label, s.dataset, percent(s.score), *(percent(weights[s]) if weighted), dollars(s.usage.cost_usd), s.permalink || "-"]
156
209
  end
157
210
  table.call(rows, header: ["model", "effort", "dataset", "score", *("wscore" if weighted), "cost", "url"], fill: 0)
158
211
  end
@@ -161,14 +214,15 @@ module LLM
161
214
  # the color of its progress bars.
162
215
  #
163
216
  # @param title [String]
164
- # @param summaries [Array<Experiment::Summary>]
217
+ # @param summaries [Array<Actions::Experiment::Summary>]
165
218
  # @param label [String] the name of what the block reads
166
- # @yieldparam summary [Experiment::Summary]
219
+ # @yieldparam summary [Actions::Experiment::Summary]
167
220
  # @yieldreturn [Float]
221
+ # @return [void]
168
222
  def winners(title, summaries, label)
169
223
  return if summaries.empty?
170
224
 
171
- rows = summaries.map { ["[#{it.dataset}]", "effort=#{it.model.effort || "default"}", "#{label}=#{percent(yield(it))}", dollars(it.usage.cost_usd)] }
225
+ rows = summaries.map { ["[#{it.dataset}]", "effort=#{it.model.effort_label}", "#{label}=#{percent(yield(it))}", dollars(it.usage.cost_usd)] }
172
226
  widths = rows.transpose.map { |column| column.map(&:length).max }
173
227
  out.puts title
174
228
  rows.zip(summaries).each do |(dataset, effort, value, cost), summary|
@@ -9,11 +9,14 @@ module LLM
9
9
  # What a model can be drawn in. More models than these share them.
10
10
  COLORS = %i[bright_red bright_green bright_yellow bright_blue bright_magenta bright_cyan bright_white].freeze
11
11
 
12
+ # Shuffles once, so the colors change between runs and hold within one.
12
13
  def initialize
13
14
  palette = COLORS.shuffle
14
15
  @colors = Hash.new { |colors, model| colors[model] = palette[colors.size % palette.size] }
15
16
  end
16
17
 
18
+ # Hands out the next color the first time a model is asked about.
19
+ #
17
20
  # @param model [String] the model's id
18
21
  # @return [Symbol] a Pastel style
19
22
  def [](model) = @colors[model]