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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +163 -87
- data/exe/llm-evals +60 -5
- data/lib/.DS_Store +0 -0
- data/lib/llm/.DS_Store +0 -0
- data/lib/llm/evals/actions/adapter.rb +50 -0
- data/lib/llm/evals/actions/error_budget.rb +63 -0
- data/lib/llm/evals/actions/error_diagnosis.rb +51 -0
- data/lib/llm/evals/actions/error_streak.rb +83 -0
- data/lib/llm/evals/actions/experiment.rb +251 -0
- data/lib/llm/evals/actions/verify.rb +61 -0
- data/lib/llm/evals/actions.rb +9 -0
- data/lib/llm/evals/cli/cases.rb +10 -0
- data/lib/llm/evals/cli/command.rb +32 -99
- data/lib/llm/evals/cli/compare.rb +0 -20
- data/lib/llm/evals/cli/contender_selector.rb +61 -0
- data/lib/llm/evals/cli/error_pause.rb +81 -0
- data/lib/llm/evals/cli/experiment_pool.rb +97 -0
- data/lib/llm/evals/cli/formatting.rb +8 -0
- data/lib/llm/evals/cli/leaderboard.rb +66 -12
- data/lib/llm/evals/cli/model_colors.rb +3 -0
- data/lib/llm/evals/cli/model_command.rb +152 -315
- data/lib/llm/evals/cli/model_options.rb +80 -0
- data/lib/llm/evals/cli/option_groups.rb +32 -0
- data/lib/llm/evals/cli/progress_bars.rb +92 -5
- data/lib/llm/evals/cli/render.rb +6 -6
- data/lib/llm/evals/cli/report_dispatcher/console.rb +65 -0
- data/lib/llm/evals/cli/report_dispatcher/html_output.rb +50 -0
- data/lib/llm/evals/cli/report_dispatcher/json_output.rb +29 -0
- data/lib/llm/evals/cli/report_dispatcher/output.rb +56 -0
- data/lib/llm/evals/cli/report_dispatcher/pipeline_output.rb +38 -0
- data/lib/llm/evals/cli/report_dispatcher.rb +133 -0
- data/lib/llm/evals/cli/run.rb +13 -22
- data/lib/llm/evals/cli/run_planner.rb +97 -0
- data/lib/llm/evals/cli/run_record.rb +68 -0
- data/lib/llm/evals/cli/standings.rb +39 -17
- data/lib/llm/evals/cli/status_box.rb +51 -0
- data/lib/llm/evals/cli/table.rb +103 -0
- data/lib/llm/evals/cli/text.rb +22 -0
- data/lib/llm/evals/cli/tracker_resolver.rb +52 -0
- data/lib/llm/evals/cli/upgrade_config.rb +60 -0
- data/lib/llm/evals/cli/verify.rb +5 -1
- data/lib/llm/evals/cli/version.rb +4 -0
- data/lib/llm/evals/cli/weights.rb +48 -0
- data/lib/llm/evals/cli.rb +39 -6
- data/lib/llm/evals/config/contender_builder.rb +30 -0
- data/lib/llm/evals/config/error_limits.rb +41 -0
- data/lib/llm/evals/config/listing.rb +153 -0
- data/lib/llm/evals/config/results.rb +42 -0
- data/lib/llm/evals/config/score_weights.rb +25 -0
- data/lib/llm/evals/config/upgrade.rb +107 -0
- data/lib/llm/evals/config.rb +90 -79
- data/lib/llm/evals/datasets/case.rb +46 -0
- data/lib/llm/evals/datasets/discovery.rb +143 -0
- data/lib/llm/evals/datasets.rb +9 -0
- data/lib/llm/evals/launcher.rb +57 -3
- data/lib/llm/evals/models/contender.rb +51 -0
- data/lib/llm/evals/models/model.rb +87 -0
- data/lib/llm/evals/models/throttle.rb +170 -0
- data/lib/llm/evals/models.rb +9 -0
- data/lib/llm/evals/providers/base.rb +61 -16
- data/lib/llm/evals/providers/catalog.rb +5 -2
- data/lib/llm/evals/providers/open_router.rb +54 -7
- data/lib/llm/evals/providers/registry.rb +49 -16
- data/lib/llm/evals/providers/type_safe/client.rb +67 -0
- data/lib/llm/evals/providers/type_safe.rb +113 -12
- data/lib/llm/evals/providers.rb +9 -0
- data/lib/llm/evals/render/reports/call_stats_table.rb +44 -0
- data/lib/llm/evals/render/reports/html/dataset_section.rb +51 -0
- data/lib/llm/evals/render/reports/html/format.rb +38 -0
- data/lib/llm/evals/render/reports/html/global_standings.rb +41 -0
- data/lib/llm/evals/render/reports/html/ranking.rb +86 -0
- data/lib/llm/evals/render/reports/html/view_model.rb +116 -0
- data/lib/llm/evals/render/reports/html.rb +60 -181
- data/lib/llm/evals/render/reports/json/decoder.rb +137 -0
- data/lib/llm/evals/render/reports/json/encoder.rb +106 -0
- data/lib/llm/evals/render/reports/json.rb +38 -141
- data/lib/llm/evals/render/reports/pipeline.rb +55 -20
- data/lib/llm/evals/render/reports/terminal.rb +8 -1
- data/lib/llm/evals/render.rb +9 -0
- data/lib/llm/evals/results/provider_response.rb +25 -0
- data/lib/llm/evals/results/reply.rb +38 -0
- data/lib/llm/evals/results/run_outcome.rb +15 -0
- data/lib/llm/evals/results/run_result.rb +40 -0
- data/lib/llm/evals/results/score.rb +45 -0
- data/lib/llm/evals/results/warnings.rb +149 -0
- data/lib/llm/evals/results.rb +9 -0
- data/lib/llm/evals/runtime/configuration.rb +295 -0
- data/lib/llm/evals/runtime/environment.rb +90 -0
- data/lib/llm/evals/runtime/helper.rb +153 -0
- data/lib/llm/evals/runtime.rb +2 -67
- data/lib/llm/evals/scoring/metric.rb +47 -0
- data/lib/llm/evals/scoring/schema_validator.rb +102 -0
- data/lib/llm/evals/scoring/scorer.rb +49 -0
- data/lib/llm/evals/scoring/weighted_score.rb +148 -0
- data/lib/llm/evals/scoring.rb +9 -0
- data/lib/llm/evals/stats/call_stats.rb +91 -0
- data/lib/llm/evals/stats/usage.rb +46 -0
- data/lib/llm/evals/stats.rb +29 -0
- data/lib/llm/evals/testing/echo_adapter.rb +14 -9
- data/lib/llm/evals/testing/fake_model.rb +8 -6
- data/lib/llm/evals/testing/fake_provider.rb +39 -8
- data/lib/llm/evals/testing.rb +9 -0
- data/lib/llm/evals/trackers/base.rb +110 -0
- data/lib/llm/evals/trackers/braintrust.rb +40 -3
- data/lib/llm/evals/trackers/local.rb +38 -2
- data/lib/llm/evals/trackers/logfire.rb +56 -3
- data/lib/llm/evals/trackers/registry.rb +29 -7
- data/lib/llm/evals/trackers.rb +9 -0
- data/lib/llm/evals/version.rb +3 -1
- data/lib/llm/evals.rb +17 -7
- metadata +91 -24
- data/lib/llm/evals/adapter.rb +0 -47
- data/lib/llm/evals/case.rb +0 -36
- data/lib/llm/evals/completion.rb +0 -11
- data/lib/llm/evals/configuration.rb +0 -173
- data/lib/llm/evals/contender.rb +0 -31
- data/lib/llm/evals/discovery.rb +0 -95
- data/lib/llm/evals/experiment.rb +0 -158
- data/lib/llm/evals/helper.rb +0 -98
- data/lib/llm/evals/metric.rb +0 -26
- data/lib/llm/evals/model.rb +0 -55
- data/lib/llm/evals/reply.rb +0 -18
- data/lib/llm/evals/run_result.rb +0 -14
- data/lib/llm/evals/schema_validator.rb +0 -84
- data/lib/llm/evals/score.rb +0 -26
- data/lib/llm/evals/scorer.rb +0 -45
- data/lib/llm/evals/tracker.rb +0 -80
- data/lib/llm/evals/usage.rb +0 -33
- data/lib/llm/evals/verify.rb +0 -49
- data/lib/llm/evals/warnings.rb +0 -98
- data/lib/llm/evals/weighted_score.rb +0 -116
- /data/lib/llm/evals/{templates → render/templates}/html_report.css +0 -0
- /data/lib/llm/evals/{templates → render/templates}/html_report.html.erb +0 -0
- /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
|
-
|
|
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}#{
|
|
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}
|
|
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
|
|
data/lib/llm/evals/cli/render.rb
CHANGED
|
@@ -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
|
data/lib/llm/evals/cli/run.rb
CHANGED
|
@@ -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
|
|