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
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module CLI
6
+ # Lists a command's flags by the `group:` each declares, since dry-cli-help
7
+ # prints them in one list. A command with no groups keeps the plain OPTIONS.
8
+ module OptionGroups
9
+ # Each group's heading, in the order they print.
10
+ HEADINGS = { general: "General Options", tui: "TUI Options", browser: "Browser Report Options" }.freeze
11
+
12
+ private
13
+
14
+ # Overrides `Dry::CLI::Help::Screens::Command#render_options`, which prints one
15
+ # list. The help row goes last among the general options.
16
+ #
17
+ # @return [Array<String>] the section's lines
18
+ def render_options
19
+ shown = command.options.reject { it.options.fetch(:hidden, false) && !include_hidden? }
20
+ return super if shown.none? { it.options[:group] }
21
+
22
+ groups = shown.zip(option_rows).group_by { |option, _row| option.options.fetch(:group, :general) }
23
+ HEADINGS.flat_map do |group, heading|
24
+ rows = groups.fetch(group, []).map(&:last)
25
+ rows << self.class::HELP if group == :general
26
+ [format.title(heading), *definitions(rows), ""]
27
+ end[0...-1]
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -8,7 +8,8 @@ module LLM
8
8
  # - a flat list, the jobs indented under the headline, with no tree;
9
9
  # - the headline's bar, which counts every case, in yellow;
10
10
  # - a finished job keeps its bar, full, with the time it took where the
11
- # ETA was, instead of turning into a one-line summary;
11
+ # ETA was, instead of turning into a one-line summary; red says what failed,
12
+ # so no count follows it;
12
13
  # - on a screen too short for every job, a finished row stays for
13
14
  # {LINGER} seconds, since a short job would otherwise leave the screen
14
15
  # before anyone saw it full: at two cases, its last frame is 50%;
@@ -38,15 +39,26 @@ module LLM
38
39
  # Tells the terminal to show a frame only once all of it has arrived.
39
40
  # Terminals that do not know these ignore them.
40
41
  SYNC_START = "\e[?2026h"
42
+ # Ends the update {SYNC_START} began.
41
43
  SYNC_END = "\e[?2026l"
42
44
 
45
+ # Matches one color code, which takes no column, or one character, which does.
46
+ ANSI_OR_CHARACTER = /\e\[[\d;]*[A-Za-z]|./
47
+
43
48
  # Stands between a label's fixed columns and its model.
44
49
  MODEL_SEPARATOR = " | "
45
50
 
51
+ # Set by {.for}, to share the colors with whatever draws the models next.
52
+ #
53
+ # @return [ModelColors]
54
+ # @param value [ModelColors]
46
55
  attr_writer :model_colors
47
56
 
57
+ # Reaches into the console's private stream, clock and config, which dry-cli-ui offers no other way to pass along.
58
+ #
48
59
  # @param console [Dry::CLI::UI::Console] the command's ui, whose stream, clock and settings it draws with
49
60
  # @param colors [ModelColors] shared with whatever else draws the models
61
+ # @return [ProgressBars]
50
62
  def self.for(console, colors: ModelColors.new)
51
63
  new(console.send(:err), clock: console.send(:clock), config: console.send(:config)).tap { it.model_colors = colors }
52
64
  end
@@ -55,7 +67,7 @@ module LLM
55
67
  # the widest of its column, so that every model starts in one column:
56
68
  # `[01-bookkeeping-cfo] effort=low t=0.2 | moonshotai/kimi-k3`.
57
69
  #
58
- # @param runs [Array<Experiment>]
70
+ # @param runs [Array<Actions::Experiment>]
59
71
  # @return [Array<String>] in the order given
60
72
  def self.labels(runs)
61
73
  datasets = runs.map { "[#{it.dataset}]" }
@@ -66,27 +78,57 @@ module LLM
66
78
  end
67
79
  end
68
80
 
81
+ # Stops drawing while the block runs, so a question can use the screen, then
82
+ # starts again below it. The widget's own position no longer means anything by
83
+ # then, so nothing is overwritten.
84
+ #
85
+ # @yieldreturn [Object]
86
+ # @return [Object] what the block returns
87
+ def pausing
88
+ lock.synchronize { @paused = true }
89
+ yield
90
+ ensure
91
+ lock.synchronize do
92
+ @paused = false
93
+ @drawn = 0
94
+ redraw if live?
95
+ end
96
+ end
97
+
69
98
  private
70
99
 
100
+ # Overrides `Dry::CLI::UI::Widgets::Multi#label_width`: never under {LABEL_WIDTH}, and with room for the indent.
101
+ #
71
102
  # @return [Integer]
72
103
  def label_width = [LABEL_WIDTH, title.length, *jobs.map { it.label.length }].max + INDENT.length
73
104
 
105
+ # Overrides `Dry::CLI::UI::Widgets::Multi#rows`: a flat list, each job indented and its model painted.
106
+ #
107
+ # @return [Array<String>] the headline, then one row per job shown
74
108
  def rows
75
109
  width = label_width
76
110
  [headline(width), *visible.map { "#{INDENT}#{painted(row(it, width - INDENT.length))}" }]
77
111
  end
78
112
 
113
+ # Paints only what follows {MODEL_SEPARATOR}, leaving the meter's colors alone.
114
+ #
79
115
  # @param text [String] a job's row, whose label holds the width it was padded to
80
116
  # @return [String] the row with its model in that model's color
81
117
  def painted(text)
82
118
  text.sub(/(?<=#{Regexp.escape(MODEL_SEPARATOR)})\S+/) { terminal.pastel.public_send(model_colors[it], it) }
83
119
  end
84
120
 
121
+ # Stands in for a bar built without {.for}.
122
+ #
123
+ # @return [ModelColors]
85
124
  def model_colors = @model_colors ||= ModelColors.new
86
125
 
87
126
  # The running jobs and those just finished, in the order declared. The
88
127
  # finished get at most half the rows, newest first: when jobs end
89
128
  # faster than rows linger, the oldest must not hold the screen.
129
+ # Overrides `Dry::CLI::UI::Widgets::Multi#visible`.
130
+ #
131
+ # @return [Array<Dry::CLI::UI::Widgets::Multi::Job>]
90
132
  def visible
91
133
  return super unless crowded? && state == :running
92
134
 
@@ -96,12 +138,27 @@ module LLM
96
138
  jobs & (finished + jobs.select { it.state == :running }.first(slots - finished.size))
97
139
  end
98
140
 
141
+ # Whether a finished job still gets its row.
142
+ #
143
+ # @param job [Dry::CLI::UI::Widgets::Multi::Job]
144
+ # @param now [Float] by the clock
145
+ # @return [Boolean]
99
146
  def lingering?(job, now) = ENDED.include?(job.state) && now - finished_at(job) < LINGER
100
147
 
148
+ # Derived, since a job records when it started and how long it took.
149
+ #
150
+ # @param job [Dry::CLI::UI::Widgets::Multi::Job] one that has ended
151
+ # @return [Float] by the clock
101
152
  def finished_at(job) = job.started + job.seconds
102
153
 
154
+ # Overrides `Dry::CLI::UI::Widgets::Multi#redraw`, which clears the screen below
155
+ # first and flickers with many rows; clears only when the frame got shorter.
156
+ #
157
+ # @return [void]
103
158
  def redraw
104
- drawn = rows
159
+ return if @paused
160
+
161
+ drawn = rows.map { clipped(it) }
105
162
  cursor = terminal.cursor
106
163
  up = @drawn.positive? ? cursor.up(@drawn) : ""
107
164
  lines = drawn.map { "\r#{it}#{cursor.clear_line_after}\n" }.join
@@ -110,23 +167,53 @@ module LLM
110
167
  @drawn = drawn.size
111
168
  end
112
169
 
170
+ # Cuts a row to the screen's width, keeping its color codes. A row that
171
+ # wrapped would take two lines while {#redraw} counts one, and each frame
172
+ # would then start lower than the last, leaving the old ones on the screen.
173
+ #
174
+ # @param text [String] a row, perhaps colored
175
+ # @return [String]
176
+ def clipped(text)
177
+ room = terminal.width - 1
178
+ text.scan(ANSI_OR_CHARACTER).select { it.start_with?("\e") || (room -= 1) >= 0 }.join
179
+ end
180
+
181
+ # Overrides `Dry::CLI::UI::Widgets::Multi#headline`: live, it keeps its bar to the
182
+ # end, in yellow, with the time taken in place of the ETA.
183
+ #
184
+ # @param width [Integer] the columns its title is padded to
185
+ # @return [String]
113
186
  def headline(width)
114
187
  return super unless live?
115
188
 
116
189
  finished = state != :running
117
190
  bar = full_meter(current, total, started, HEADLINE_COLOR, counts, (@seconds if finished))
118
- "#{glyph(state)} #{title.ljust(width)} #{bar}#{finished ? noted(headline_note) : stopping}"
191
+ "#{glyph(state)} #{title.ljust(width)} #{bar}#{stopping unless finished}"
119
192
  end
120
193
 
194
+ # Overrides `Dry::CLI::UI::Widgets::Multi#row`, so that a finished bar stays full
195
+ # instead of turning into a one-line summary.
196
+ #
197
+ # @param job [Dry::CLI::UI::Widgets::Multi::Job]
198
+ # @param width [Integer] the columns its label is padded to
199
+ # @return [String]
121
200
  def row(job, width)
122
201
  return super unless ENDED.include?(job.state) && !spinner?(job) && live?
123
202
 
124
203
  handle = job.handle
125
204
  bar = full_meter(handle.current, handle.total, job.started, handle.color, handle.counts, job.seconds.to_f)
126
- "#{glyph(job.state)} #{job.label.ljust(width)} #{bar}#{noted(note(job))}"
205
+ "#{glyph(job.state)} #{job.label.ljust(width)} #{bar}"
127
206
  end
128
207
 
129
208
  # {#meter}, with the time taken in place of the ETA once seconds are known.
209
+ #
210
+ # @param done [Integer]
211
+ # @param all [Integer, nil]
212
+ # @param since [Float] when the work started, by the clock
213
+ # @param color [Symbol, nil] the bar's own color, if any
214
+ # @param tally [Hash{Symbol => Integer}, nil] units by outcome
215
+ # @param seconds [Float, nil] the time taken; nil while the work runs
216
+ # @return [String]
130
217
  def full_meter(done, all, since, color, tally, seconds)
131
218
  return meter(done, all, since, color, tally) if seconds.nil?
132
219
 
@@ -10,6 +10,12 @@ module LLM
10
10
  argument :input, required: true, desc: "JSON report file produced by run or compare --format json"
11
11
  option :output, aliases: ["-o"], desc: "HTML output path (default: log/<project>-llm-evals.report.html)"
12
12
 
13
+ # Writes the page beside the other reports, to the given path, or to STDOUT for `-o -`.
14
+ #
15
+ # @param input [String] the path of the saved JSON
16
+ # @param output [String, nil] what -o was given: nil or "" for the default file, "-", or a path
17
+ # @return [void]
18
+ # @raise [Error] on a report of an unknown format, version or provider
13
19
  def call(input:, output: nil, **)
14
20
  source = File.read(input)
15
21
  saved = LLM::Evals::Render::Reports::JSON.decode(source, providers: configuration.providers)
@@ -25,12 +31,6 @@ module LLM
25
31
  out.write(document) unless path
26
32
  status_box(:success, "HTML report written to #{relative(path)}", stream: err) if path
27
33
  end
28
-
29
- private
30
-
31
- def invocation = ui.send(:invocation)
32
-
33
- def relative(path) = Pathname(path).relative_path_from(Dir.pwd).to_s
34
34
  end
35
35
  end
36
36
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module LLM
6
+ module Evals
7
+ module CLI
8
+ class ReportDispatcher
9
+ # What an output may say and open, without knowing the command.
10
+ class Console
11
+ # Takes callables, so it can stand in for the command without holding it.
12
+ #
13
+ # @param out [IO] takes a document written to STDOUT
14
+ # @param status [#call] draws a status box on the error stream: level, text
15
+ # @param save_warnings [#call] saves what the run held back from the screen
16
+ # @param browser [#browse]
17
+ def initialize(out:, status:, save_warnings:, browser:)
18
+ @out = out
19
+ @status = status
20
+ @save_warnings = save_warnings
21
+ @browser = browser
22
+ end
23
+
24
+ # Says where a file went.
25
+ #
26
+ # @param label [String] such as "JSON report"
27
+ # @param path [String]
28
+ # @return [void]
29
+ def written(label, path) = @status.call(:success, "#{label} written to #{relative(path)}")
30
+
31
+ # A warning rather than a failure, since the report exists either way.
32
+ #
33
+ # @param target [String] what could not be opened, as it is named to the user
34
+ # @return [void]
35
+ def could_not_open(target) = @status.call(:warning, "could not open #{target}")
36
+
37
+ # Asks the runtime, which knows this platform's opener.
38
+ #
39
+ # @param url [String]
40
+ # @return [Boolean] whether a browser took it
41
+ def browse(url) = @browser.browse(url)
42
+
43
+ # Writes it as it is, with no newline added, so a piped document is unchanged.
44
+ #
45
+ # @param document [String]
46
+ # @return [void]
47
+ def print(document) = @out.write(document)
48
+
49
+ # Left to the command, which knows where its warnings belong.
50
+ #
51
+ # @return [void]
52
+ def save_warnings = @save_warnings.call
53
+
54
+ private
55
+
56
+ # As {Command#relative}, which is out of reach from here.
57
+ #
58
+ # @param path [String]
59
+ # @return [String] relative to the working directory
60
+ def relative(path) = Pathname(path).relative_path_from(Dir.pwd).to_s
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module LLM
6
+ module Evals
7
+ module CLI
8
+ class ReportDispatcher
9
+ # --format html: the page, from the same JSON a later `render` would read.
10
+ class HtmlOutput < Output
11
+ # Writes the page, and opens it unless it went to STDOUT.
12
+ #
13
+ # @see Output#write
14
+ # @param snapshot [Snapshot]
15
+ # @return [void]
16
+ def write(snapshot)
17
+ json = json_for(snapshot)
18
+ path = output_path(snapshot.output, "html")
19
+ document = pipeline.write_html(json:, path:)
20
+ path ? console.written("HTML report", path) : console.print(document)
21
+ console.save_warnings
22
+ open_page(path) if snapshot.open && path
23
+ end
24
+
25
+ private
26
+
27
+ # With autosave the JSON is saved and read back, so the page holds what the file holds.
28
+ #
29
+ # @param snapshot [Snapshot]
30
+ # @return [String] the JSON text
31
+ def json_for(snapshot)
32
+ return pipeline.write_json(**snapshot.document, path: nil) unless snapshot.results[:autosave]
33
+
34
+ path = pipeline.save_json(**snapshot.document)
35
+ console.written("JSON results", path)
36
+ File.read(path)
37
+ end
38
+
39
+ # Warns when no browser takes it; the page is on disk either way.
40
+ #
41
+ # @param path [String]
42
+ # @return [void]
43
+ def open_page(path)
44
+ console.could_not_open(path) unless console.browse(URI::DEFAULT_PARSER.escape("file://#{path}"))
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module CLI
6
+ class ReportDispatcher
7
+ # --format json: the snapshot itself, saved beside the others unless -o
8
+ # names a place for it.
9
+ class JsonOutput < Output
10
+ # Writes the document, or prints it for `-o -`.
11
+ #
12
+ # @see Output#write
13
+ # @param snapshot [Snapshot]
14
+ # @return [void]
15
+ def write(snapshot)
16
+ if snapshot.output.nil? || snapshot.output.empty?
17
+ console.written("JSON report", pipeline.save_json(**snapshot.document))
18
+ else
19
+ path = output_path(snapshot.output, "json")
20
+ document = pipeline.write_json(**snapshot.document, path:)
21
+ path ? console.written("JSON report", path) : console.print(document)
22
+ end
23
+ console.save_warnings
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module CLI
6
+ class ReportDispatcher
7
+ # One way of delivering a run.
8
+ class Output
9
+ # Holds only collaborators; nothing is written until {#write}.
10
+ #
11
+ # @param pipeline [LLM::Evals::Render::Reports::Pipeline]
12
+ # @param console [Console]
13
+ # @param invocation [Dry::CLI::UI::Invocation] names the default files
14
+ def initialize(pipeline:, console:, invocation:)
15
+ @pipeline = pipeline
16
+ @console = console
17
+ @invocation = invocation
18
+ end
19
+
20
+ # Abstract; each subclass is one delivery.
21
+ #
22
+ # @param snapshot [Snapshot]
23
+ # @yield [report] draw the decoded report in the terminal, for an output that does
24
+ # @return [void]
25
+ # @raise [NotImplementedError] unless a subclass implements it
26
+ def write(snapshot, &) = raise NotImplementedError, "#{self.class} must implement #write"
27
+
28
+ private
29
+
30
+ attr_reader :pipeline, :console
31
+
32
+ # @!attribute [r] pipeline
33
+ # Writes the files this output asks for.
34
+ # @return [LLM::Evals::Render::Reports::Pipeline]
35
+
36
+ # @!attribute [r] console
37
+ # What this output may say and open.
38
+ # @return [Console]
39
+
40
+ # Where -o sends this format: the default report name without a path, and a
41
+ # path's .log ending swapped for the extension.
42
+ #
43
+ # @param output [String, nil] what -o was given
44
+ # @param extension [String]
45
+ # @return [String, nil] nil means STDOUT
46
+ def output_path(output, extension)
47
+ return nil if output == "-"
48
+ return pipeline.report_path(File.dirname(@invocation.report_path("")), extension) if output.nil? || output.empty?
49
+
50
+ @invocation.report_path(output).sub(/\.log\z/, ".#{extension}")
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module CLI
6
+ class ReportDispatcher
7
+ # No format, or tui: the display and autosave the lineup configures.
8
+ class PipelineOutput < Output
9
+ # Lets the pipeline decide, then says what it wrote.
10
+ #
11
+ # @see Output#write
12
+ # @param snapshot [Snapshot]
13
+ # @yield [report] draw the decoded report in the terminal, when the display does
14
+ # @return [void]
15
+ def write(snapshot, &)
16
+ results = snapshot.results
17
+ result = pipeline.call(**snapshot.document, autosave: results[:autosave], display: results[:display], open: snapshot.open, &)
18
+ console.written("JSON results", result.json_path) if result.json_path
19
+ announce_page(result, snapshot.open) if result.html_path
20
+ console.save_warnings unless Config::TERMINAL_DISPLAYS.include?(results[:display]) # the terminal view has said so already
21
+ end
22
+
23
+ private
24
+
25
+ # Says where the page went, and warns when it would not open.
26
+ #
27
+ # @param result [LLM::Evals::Render::Reports::Pipeline::Result]
28
+ # @param open [Boolean] whether opening it was asked for
29
+ # @return [void]
30
+ def announce_page(result, open)
31
+ console.written("HTML report", result.html_path)
32
+ console.could_not_open(result.html_path) if open && !result.browser_opened
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module CLI
6
+ # Sends a finished run to the output its format asks for: an HTML page, a
7
+ # JSON document, or, with no format, whatever the lineup's results section
8
+ # configures. The command keeps the terminal view, which draws on its own
9
+ # streams, and hands it over as a block.
10
+ class ReportDispatcher
11
+ # What --format asks of results.display.
12
+ DISPLAYS = { "tui" => "tui", "none" => "none", "html" => "browser", "json" => "none" }.freeze
13
+
14
+ # One run on its way out.
15
+ #
16
+ # @!attribute [r] summaries
17
+ # @return [Array<Actions::Experiment::Summary>]
18
+ # @!attribute [r] metrics
19
+ # @return [Array<Scoring::Metric>]
20
+ # @!attribute [r] weighted_score
21
+ # @return [Scoring::WeightedScore, nil]
22
+ # @!attribute [r] run_options
23
+ # @return [Hash] what the saved report says about its run, results included
24
+ # @!attribute [r] results
25
+ # @return [Hash{Symbol => Object}] the settled autosave, destination, temp_dir and display
26
+ # @!attribute [r] output
27
+ # @return [String, nil] what -o was given
28
+ # @!attribute [r] open
29
+ # @return [Boolean] whether to open what gets written
30
+ Snapshot = Data.define(:summaries, :metrics, :weighted_score, :run_options, :results, :output, :open) do
31
+ # Leaves out how and where it is delivered, which the saved JSON has no use for.
32
+ #
33
+ # @return [Hash] what {Render::Reports::Pipeline} serializes
34
+ def document = { summaries:, metrics:, weighted_score:, run_options: }
35
+ end
36
+
37
+ # Holds only collaborators; nothing is written until {#call}.
38
+ #
39
+ # @param config [Config]
40
+ # @param options [Hash] the parsed flags
41
+ # @param providers [Providers::Registry]
42
+ # @param runtime [Runtime::Environment]
43
+ # @param suite [String] names the files when the run has no project
44
+ # @param console [Console]
45
+ # @param invocation [Dry::CLI::UI::Invocation]
46
+ def initialize(config:, options:, providers:, runtime:, suite:, console:, invocation:)
47
+ @config = config
48
+ @options = options
49
+ @providers = providers
50
+ @runtime = runtime
51
+ @suite = suite
52
+ @console = console
53
+ @invocation = invocation
54
+ end
55
+
56
+ # Picks the output for the format and hands it the snapshot.
57
+ #
58
+ # @param summaries [Array<Actions::Experiment::Summary>]
59
+ # @param metrics [Array<Scoring::Metric>]
60
+ # @param weighted_score [Scoring::WeightedScore, nil]
61
+ # @param run_options [Hash]
62
+ # @param output [String, nil] what -o was given: nil, "-", "" for the default file, or a path
63
+ # @param open [Boolean]
64
+ # @param format [String, nil] `tui`, `html` or `json`
65
+ # @param terminal [Proc] the block below
66
+ # @yield [report] draw the decoded report in the terminal
67
+ # @yieldparam report [LLM::Evals::Render::Reports::JSON::Loaded]
68
+ # @return [void]
69
+ def call(summaries, metrics:, weighted_score:, run_options:, output:, open:, format: nil, &terminal)
70
+ results = effective_results(format)
71
+ snapshot = Snapshot.new(summaries:,
72
+ metrics:,
73
+ weighted_score:,
74
+ output:,
75
+ open:,
76
+ results:,
77
+ run_options: run_options.merge(results:))
78
+ strategy(format).new(pipeline: pipeline(results, run_options), console: @console, invocation: @invocation)
79
+ .write(snapshot, &terminal)
80
+ end
81
+
82
+ # Lets the command tell in advance whether the terminal view will draw.
83
+ #
84
+ # @param format [String, nil] `tui`, `html` or `json`
85
+ # @return [String] the display this run settles on
86
+ def display(format = nil) = effective_results(format)[:display]
87
+
88
+ private
89
+
90
+ # Anything but html or json leaves the choice to the lineup.
91
+ #
92
+ # @param format [String, nil] `tui`, `html` or `json`
93
+ # @return [Class<Output>]
94
+ def strategy(format)
95
+ case format
96
+ when "html" then HtmlOutput
97
+ when "json" then JsonOutput
98
+ else PipelineOutput
99
+ end
100
+ end
101
+
102
+ # The flags over the lineup's results section.
103
+ #
104
+ # @param format [String, nil] `tui`, `html` or `json`, which outranks --display
105
+ # @return [Hash{Symbol => Object}] autosave, destination, temp_dir and display
106
+ def effective_results(format)
107
+ configured = @config.results
108
+ results = {
109
+ autosave: @options[:autosave].nil? ? configured.autosave : @options[:autosave],
110
+ destination: File.expand_path(@options[:destination] || configured.destination),
111
+ temp_dir: File.expand_path(@options[:temp_dir] || configured.temp_dir),
112
+ display: @options[:display] || configured.display_mode
113
+ }
114
+ results[:display] = DISPLAYS.fetch(format, results[:display]) if format
115
+ results
116
+ end
117
+
118
+ # Looks the project up under symbol and string keys alike, and falls back on the suite's name.
119
+ #
120
+ # @param results [Hash{Symbol => Object}] from {#effective_results}
121
+ # @param run_options [Hash]
122
+ # @return [LLM::Evals::Render::Reports::Pipeline]
123
+ def pipeline(results, run_options)
124
+ LLM::Evals::Render::Reports::Pipeline.new(providers: @providers,
125
+ runtime: @runtime,
126
+ destination: results[:destination],
127
+ temp_dir: results[:temp_dir],
128
+ project: run_options.dig(:effective, :project) || run_options.dig("effective", "project") || @suite)
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -17,31 +17,19 @@ module LLM
17
17
  "-n # show what would run, call nothing"
18
18
  ]
19
19
 
20
- def call(models:, dataset:, dry_run:, **options)
21
- config = lineup(options)
22
- weigh(config)
23
- contenders = contenders(config, models, options)
24
- datasets = datasets(dataset)
25
- return matrix(datasets, contenders) if dry_run
26
-
27
- setup!(contenders, options)
28
- runs = datasets.flat_map { |name| contenders.map { experiment(it, config, options, dataset: name) } }
29
- summaries = execute(runs, concurrency: concurrency(options))
30
- report(summaries,
31
- output: options[:output],
32
- open: options[:open],
33
- scores: options[:scores],
34
- format: options[:format],
35
- config:,
36
- options:,
37
- run_options: serialize_run_options(config:, contenders:, datasets:, runs:, models:, dataset:, options:)) do |display_summaries|
38
- display_summaries.each { breakdown(it, titled: display_summaries.size > 1) }
39
- end
40
- end
41
-
42
20
  private
43
21
 
22
+ # Overrides {ModelCommand#details}. A single experiment's breakdown needs no title.
23
+ #
24
+ # @param summaries [Array<Actions::Experiment::Summary>]
25
+ # @return [void]
26
+ def details(summaries) = summaries.each { breakdown(it, titled: summaries.size > 1) }
27
+
44
28
  # One row per case: its score, and whatever the scorer diagnosed.
29
+ #
30
+ # @param summary [Actions::Experiment::Summary]
31
+ # @param titled [Boolean] print its name above the table
32
+ # @return [void]
45
33
  def breakdown(summary, titled: false)
46
34
  status_box :info, summary.name if titled
47
35
  rows = summary.results.map do |result|
@@ -52,6 +40,9 @@ module LLM
52
40
 
53
41
  # The scorer's diagnostics, one per line, rendered without knowing
54
42
  # their shape: "field: value" for a Hash, each item for an Array.
43
+ #
44
+ # @param result [Results::RunResult]
45
+ # @return [String] the error instead, for a case whose call failed
55
46
  def diagnosis(result)
56
47
  return result.error if result.failed?
57
48