llm-evals 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +26 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +764 -0
  5. data/exe/evals +9 -0
  6. data/exe/llm-evals +14 -0
  7. data/lib/llm/evals/adapter.rb +47 -0
  8. data/lib/llm/evals/case.rb +36 -0
  9. data/lib/llm/evals/cli/cases.rb +48 -0
  10. data/lib/llm/evals/cli/command.rb +163 -0
  11. data/lib/llm/evals/cli/compare.rb +40 -0
  12. data/lib/llm/evals/cli/formatting.rb +16 -0
  13. data/lib/llm/evals/cli/leaderboard.rb +183 -0
  14. data/lib/llm/evals/cli/model_colors.rb +23 -0
  15. data/lib/llm/evals/cli/model_command.rb +437 -0
  16. data/lib/llm/evals/cli/progress_bars.rb +147 -0
  17. data/lib/llm/evals/cli/render.rb +37 -0
  18. data/lib/llm/evals/cli/run.rb +69 -0
  19. data/lib/llm/evals/cli/standings.rb +114 -0
  20. data/lib/llm/evals/cli/verify.rb +24 -0
  21. data/lib/llm/evals/cli/version.rb +15 -0
  22. data/lib/llm/evals/cli.rb +43 -0
  23. data/lib/llm/evals/completion.rb +11 -0
  24. data/lib/llm/evals/config.rb +198 -0
  25. data/lib/llm/evals/configuration.rb +173 -0
  26. data/lib/llm/evals/contender.rb +31 -0
  27. data/lib/llm/evals/discovery.rb +95 -0
  28. data/lib/llm/evals/experiment.rb +158 -0
  29. data/lib/llm/evals/helper.rb +98 -0
  30. data/lib/llm/evals/launcher.rb +37 -0
  31. data/lib/llm/evals/metric.rb +26 -0
  32. data/lib/llm/evals/model.rb +55 -0
  33. data/lib/llm/evals/providers/base.rb +93 -0
  34. data/lib/llm/evals/providers/catalog.rb +32 -0
  35. data/lib/llm/evals/providers/open_router.rb +64 -0
  36. data/lib/llm/evals/providers/registry.rb +112 -0
  37. data/lib/llm/evals/providers/type_safe.rb +53 -0
  38. data/lib/llm/evals/render/reports/html.rb +266 -0
  39. data/lib/llm/evals/render/reports/json.rb +172 -0
  40. data/lib/llm/evals/render/reports/pipeline.rb +153 -0
  41. data/lib/llm/evals/render/reports/terminal.rb +46 -0
  42. data/lib/llm/evals/reply.rb +18 -0
  43. data/lib/llm/evals/run_result.rb +14 -0
  44. data/lib/llm/evals/runtime.rb +74 -0
  45. data/lib/llm/evals/schema_validator.rb +84 -0
  46. data/lib/llm/evals/score.rb +26 -0
  47. data/lib/llm/evals/scorer.rb +45 -0
  48. data/lib/llm/evals/templates/html_report.css +206 -0
  49. data/lib/llm/evals/templates/html_report.html.erb +184 -0
  50. data/lib/llm/evals/templates/html_report.js +48 -0
  51. data/lib/llm/evals/testing/echo_adapter.rb +29 -0
  52. data/lib/llm/evals/testing/fake_model.rb +18 -0
  53. data/lib/llm/evals/testing/fake_provider.rb +67 -0
  54. data/lib/llm/evals/tracker.rb +80 -0
  55. data/lib/llm/evals/trackers/braintrust.rb +91 -0
  56. data/lib/llm/evals/trackers/local.rb +78 -0
  57. data/lib/llm/evals/trackers/logfire.rb +143 -0
  58. data/lib/llm/evals/trackers/registry.rb +82 -0
  59. data/lib/llm/evals/usage.rb +33 -0
  60. data/lib/llm/evals/verify.rb +49 -0
  61. data/lib/llm/evals/version.rb +7 -0
  62. data/lib/llm/evals/warnings.rb +98 -0
  63. data/lib/llm/evals/weighted_score.rb +116 -0
  64. data/lib/llm/evals.rb +83 -0
  65. metadata +274 -0
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Providers
6
+ # The providers in use. A `vendors:` contender goes to the first
7
+ # provider that serves its id, asked last-registered first, so a
8
+ # suite's own transport beats the defaults and OpenRouter, which claims
9
+ # everything, is always last. A `direct:` contender goes to the
10
+ # provider it names, and a direct provider is never asked about an id.
11
+ class Registry
12
+ include Enumerable
13
+
14
+ # @return [Registry] TypeSafe, then OpenRouter for everything else
15
+ def self.default = new([OpenRouter.new, TypeSafe.new])
16
+
17
+ # @param providers [Array<Base>] the last one is asked first
18
+ def initialize(providers = [])
19
+ @providers = providers.dup
20
+ end
21
+
22
+ # Registering a provider under a name already taken replaces it, so a
23
+ # helper and a required file that both register the same provider
24
+ # leave one, not two that would be asked twice.
25
+ #
26
+ # @param provider [Base]
27
+ # @return [Base] the provider
28
+ def register(provider)
29
+ @providers.reject! { it.name == provider.name }
30
+ @providers << provider
31
+ provider
32
+ end
33
+
34
+ def each(&) = @providers.reverse.each(&)
35
+
36
+ # @param target [Contender, String] a contender, or a `vendors:` model id
37
+ # @return [Base]
38
+ # @raise [Error] when no provider serves it
39
+ def for(target)
40
+ return direct(target.provider) if target.is_a?(Contender) && target.direct?
41
+
42
+ model_id = target.is_a?(Contender) ? target.model : target
43
+ find { !it.direct? && it.serves?(model_id) } ||
44
+ raise(Error, "no provider serves #{model_id.inspect}; have #{reject(&:direct?).map(&:name).join(", ")}")
45
+ end
46
+
47
+ # @param name [Symbol, String] a key under `direct:`
48
+ # @return [Base]
49
+ # @raise [Error] when no direct provider has the name
50
+ def direct(name)
51
+ find { it.direct? && it.name == name.to_s } ||
52
+ raise(Error, "no direct provider #{name.to_s.inspect}; have #{direct_names.join(", ")}")
53
+ end
54
+
55
+ # @return [Array<String>] the names `direct:` may use
56
+ def direct_names = select(&:direct?).map(&:name)
57
+
58
+ # @param contenders [Array<Contender>]
59
+ # @return [Array<Base>] each provider the contenders use, once
60
+ def used_by(contenders) = contenders.map { self.for(it) }.uniq
61
+
62
+ # @param contenders [Array<Contender>]
63
+ # @return [Array<String>] the API keys the contenders' providers need
64
+ def keys_for(contenders) = used_by(contenders).flat_map(&:env_keys).uniq
65
+
66
+ # @param contender [Contender]
67
+ # @return [Model]
68
+ def model_for(contender) = self.for(contender).model_for(contender)
69
+
70
+ # One refused contender: who, and why.
71
+ Refusal = Data.define(:contender, :reason) do
72
+ def to_s = "#{contender.model} refused: #{reason}"
73
+ end
74
+
75
+ # Splits the contenders into those whose provider produces a request
76
+ # type the suite declares and those it does not. A refused contender
77
+ # is named, never run, and never scored 0: a model the suite cannot
78
+ # even ask is not a model that answered wrongly.
79
+ #
80
+ # @param contenders [Array<Contender>]
81
+ # @param request_types [Array<Symbol>] what the suite's config declares
82
+ # @return [Array(Array<Contender>, Array<Refusal>)] admitted, refused
83
+ def admit(contenders, request_types:)
84
+ admitted, refused = contenders.partition { request_types.include?(self.for(it).request_type) }
85
+ refusals = refused.map do |contender|
86
+ needs = self.for(contender).request_type
87
+ Refusal.new(contender: contender, reason: "needs #{needs}; this suite produces #{request_types.join(", ")}")
88
+ end
89
+ [admitted, refusals]
90
+ end
91
+
92
+ # @return [Array<String>] every effort any provider accepts, for the flag's help
93
+ def efforts = flat_map(&:efforts).uniq
94
+
95
+ # Asks each provider in use for its catalog, and names every model it
96
+ # does not list.
97
+ #
98
+ # @param contenders [Array<Contender>]
99
+ # @return [void]
100
+ # @raise [Error] naming every unknown model
101
+ def verify!(contenders)
102
+ unknown = contenders.uniq(&:key).group_by { self.for(it) }.flat_map { |provider, list| provider.unknown(list.map(&:model)) }
103
+ return if unknown.empty?
104
+
105
+ raise Error,
106
+ "unknown model#{"s" if unknown.size > 1} #{unknown.join(", ")}: not listed by its provider; " \
107
+ "check the id in .llm-evals.config.yml or -m"
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Providers
6
+ # TypeSafe's own API, which serves Jev as typed questions (noul, choice,
7
+ # score) rather than chat: request type `typesafe`, reached only through
8
+ # a `direct: typesafe:` entry, whose ids read "typesafe/<name>". Jev on
9
+ # OpenRouter, `typesafe/jev-router` under `vendors:`, is a chat model
10
+ # and goes through {OpenRouter}; the contender carries which is meant.
11
+ # The gem knows this API's model list, key and pricing, enough for
12
+ # `verify` and the cost column; the request shape waits for the first
13
+ # suite that declares `request_types: [typesafe]`.
14
+ class TypeSafe < Base
15
+ PREFIX = "typesafe/"
16
+ URL = "https://api.typesafe.ai/v1/models"
17
+ KEY = "TYPESAFE_API_KEY"
18
+
19
+ # TypeSafe bills input tokens only: $0.042 per million for jev-latest
20
+ # and jev-preview (docs.typesafe.ai/models, read 2026-10-14). Output is free.
21
+ USD_PER_INPUT_TOKEN = 0.042 / 1_000_000
22
+
23
+ def name = "typesafe"
24
+ def request_type = :typesafe
25
+ def request_type_description = "typed questions: noul, choice, score"
26
+ def direct? = true
27
+ def prefix = PREFIX
28
+ def env_keys = [KEY]
29
+
30
+ # Jev takes no reasoning effort.
31
+ def efforts = []
32
+
33
+ # @return [Array<String>] the models, as "typesafe/<name>"
34
+ def catalog
35
+ Catalog.fetch(URL, "Authorization" => "Bearer #{ENV.fetch(KEY, nil)}").fetch("models").map { "#{PREFIX}#{it.fetch("name")}" }
36
+ end
37
+
38
+ # @param input_tokens [Integer]
39
+ # @return [Usage] with the cost at TypeSafe's input rate
40
+ def usage(input_tokens:, output_tokens: 0)
41
+ Usage.new(input_tokens: input_tokens, output_tokens: output_tokens, cost_usd: input_tokens * USD_PER_INPUT_TOKEN)
42
+ end
43
+
44
+ # @raise [Error] always: there is no typesafe consumer yet
45
+ def complete(model, system:, user:, json: true) # rubocop:disable Lint/UnusedMethodArgument
46
+ raise Error,
47
+ "#{model.id}: no typesafe consumer yet; the typed request shape is defined by the first suite " \
48
+ "that declares request_types: [typesafe]"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "erb"
5
+ require "json"
6
+
7
+ module LLM
8
+ module Evals
9
+ module Render
10
+ module Reports
11
+ # Builds a self-contained HTML report for completed eval runs.
12
+ # Only contenders with full, error-free coverage can lead the report.
13
+ class HTML
14
+ # Directory containing the report template and its embedded assets.
15
+ TEMPLATE_DIRECTORY = File.expand_path("../../templates", __dir__)
16
+
17
+ # @param report [JSON::Loaded] decoded from a saved JSON snapshot
18
+ # @return [String]
19
+ def self.render(report, stylesheet_path: nil, script_path: nil)
20
+ new(report, stylesheet_path:, script_path:).render
21
+ end
22
+
23
+ attr_reader :report, :stylesheet_path, :script_path
24
+
25
+ # @param report [JSON::Loaded] decoded from a saved JSON snapshot
26
+ def initialize(report, stylesheet_path: nil, script_path: nil)
27
+ @report = report
28
+ @stylesheet_path = stylesheet_path
29
+ @script_path = script_path
30
+ end
31
+
32
+ # Renders the template with report data and embedded assets.
33
+ #
34
+ # @return [String]
35
+ def render
36
+ health = health_summary
37
+ standings = global_standings
38
+ datasets = dataset_reports
39
+ ERB.new(File.read(template_path), trim_mode: "-").result(binding)
40
+ end
41
+
42
+ private
43
+
44
+ # @return [String]
45
+ def report_title = "#{configuration_name} evaluation report"
46
+
47
+ # @return [String]
48
+ def configuration_name = report.suite
49
+
50
+ def summaries = report.summaries
51
+
52
+ def metrics = report.metrics
53
+
54
+ def weighted_score = report.weighted_score
55
+
56
+ # @return [Array<RunResult>]
57
+ def results = summaries.flat_map(&:results)
58
+
59
+ # @return [Array<RunResult>]
60
+ def successful_results = results.select { it.score && !it.failed? && !it.reply&.error? }
61
+
62
+ # @return [Hash{Symbol => Object}] health counts and cost for the template
63
+ def health_summary
64
+ failed = results.count { !it.score || it.failed? || it.reply&.error? }
65
+ usage = total_usage
66
+ {
67
+ successful: successful_results.size,
68
+ total: results.size,
69
+ failed:,
70
+ input_tokens: tokens(usage.input_tokens),
71
+ output_tokens: tokens(usage.output_tokens),
72
+ cost: money(usage.cost_usd)
73
+ }
74
+ end
75
+
76
+ # @return [Hash{Symbol => Object}] report entries and ranking after coverage checks
77
+ def global_standings
78
+ expected_keys = results.map { it.case.key }.uniq
79
+ entries = configuration_groups.map { |group| contender(group, expected_keys) }
80
+ complete = entries.select { it[:complete] }
81
+ assign_weighted_scores(complete)
82
+ ranked = complete.sort_by { ranking_value(it) || -1 }
83
+
84
+ {
85
+ entries:,
86
+ expected_count: expected_keys.length,
87
+ ranked:,
88
+ leader: ranked.last,
89
+ best_coverage: entries.max_by { it[:successful] }
90
+ }
91
+ end
92
+
93
+ # @return [Array<Hash{Symbol => Object}>] data for each dataset section
94
+ def dataset_reports
95
+ summaries.group_by(&:dataset).sort.map do |dataset, group|
96
+ case_keys = group.flat_map { it.results.map { it.case.key } }.uniq
97
+ entries = group.map { contender([it], case_keys) }
98
+ complete = entries.select { it[:complete] }
99
+ assign_weighted_scores(complete)
100
+
101
+ {
102
+ name: dataset,
103
+ summaries: group,
104
+ case_keys:,
105
+ winner: complete.max_by { ranking_value(it) || -1 },
106
+ entries: entries.sort_by { ranking_value(it) || -1 }.reverse,
107
+ cases: group.flat_map(&:results).group_by { it.case.key }.values.map(&:first)
108
+ .sort_by { [it.case.variant, it.case.number || 0, it.case.id] }
109
+ }
110
+ end
111
+ end
112
+
113
+ # @param kase [Case]
114
+ # @param group [Array<Experiment::Summary>]
115
+ # @return [Array<Hash{Symbol => Object}]] runs and display values for one case
116
+ def case_results(kase, group)
117
+ group.flat_map(&:results).select { it.case.key == kase.key }.map do |run|
118
+ status = if run.failed?
119
+ run.error
120
+ elsif run.reply&.error?
121
+ run.reply.error
122
+ elsif run.score
123
+ percent(run.score.value)
124
+ else
125
+ "No score"
126
+ end
127
+ details = run.score&.diagnostics
128
+ details = { "error" => run.error } if run.failed?
129
+ { run:, status:, details: details || {} }
130
+ end
131
+ end
132
+
133
+ # @param group [Array<Experiment::Summary>]
134
+ # @param expected_keys [Array<String>]
135
+ # @return [Hash{Symbol => Object}] model coverage and score inputs
136
+ def contender(group, expected_keys)
137
+ group_results = group.flat_map(&:results).uniq { it.case.key }
138
+ successful = group_results.select { it.score && !it.failed? && !it.reply&.error? }
139
+ errors = group_results.count { !it.score || it.failed? || it.reply&.error? }
140
+ metric_values = metrics.to_h do |metric|
141
+ values = group_results.filter_map { it.score&.metrics&.fetch(metric.name, nil) }
142
+ [metric.name, mean(values)]
143
+ end
144
+ score_values = group_results.filter_map { it.score&.value unless it.failed? || it.reply&.error? }
145
+ cost = group.sum { it.usage.cost_usd }
146
+ row = metric_values.merge(
147
+ "score" => mean(score_values),
148
+ "errors" => errors,
149
+ "time" => group.sum(&:duration),
150
+ "cost" => cost
151
+ )
152
+ successful_keys = successful.map { it.case.key }.uniq
153
+
154
+ {
155
+ model: group.first.model,
156
+ successful: successful_keys.length,
157
+ total: expected_keys.length,
158
+ errors:,
159
+ cost:,
160
+ input_tokens: group.sum { it.usage.input_tokens },
161
+ output_tokens: group.sum { it.usage.output_tokens },
162
+ row:,
163
+ complete: expected_keys.length.positive? && successful_keys.sort == expected_keys.uniq.sort && errors.zero?
164
+ }
165
+ end
166
+
167
+ # @return [Array<Array<Experiment::Summary>>]
168
+ def configuration_groups
169
+ summaries.group_by { [it.model.key, it.model.effort, it.model.temperature] }.values
170
+ end
171
+
172
+ # @param entries [Array<Hash{Symbol => Object}>]
173
+ # @return [void]
174
+ def assign_weighted_scores(entries)
175
+ scores = if weighted_score
176
+ weighted_score.call(entries.map { it[:row] })
177
+ else
178
+ entries.map { it[:row][primary_metric] }
179
+ end
180
+ entries.zip(scores).each { |entry, score| entry[:ranking] = score }
181
+ end
182
+
183
+ # @param entry [Hash{Symbol => Object}]
184
+ # @return [Float, nil]
185
+ def ranking_value(entry) = entry[:ranking]
186
+
187
+ # @return [String]
188
+ def primary_metric = metrics.find(&:primary)&.name || "score"
189
+
190
+ # @return [String]
191
+ def weight_description
192
+ weighted_score ? "Weighted score from the lineup; chart order runs worst to best." : "Ranked by #{primary_metric}; chart order runs worst to best."
193
+ end
194
+
195
+ # @return [Usage]
196
+ def total_usage = summaries.map(&:usage).reduce(Usage.zero, :+)
197
+
198
+ # @param model [Model]
199
+ # @return [Array<String>] HTML-escaped provider and model names
200
+ def model_parts(model)
201
+ provider, name = model.id.to_s.split("/", 2)
202
+ [escape(provider), escape(name || provider)]
203
+ end
204
+
205
+ # @param model [Model]
206
+ # @return [String] escaped effort setting, or `default`
207
+ def effort_label(model) = escape(model.effort || "default")
208
+
209
+ # @param model [Model]
210
+ # @return [String] escaped temperature setting, or `default`
211
+ def temperature_label(model) = escape(model.temperature.nil? ? "default" : model.temperature)
212
+
213
+ # @param values [Array<Numeric>]
214
+ # @return [Float, nil]
215
+ def mean(values) = values.empty? ? nil : values.sum.fdiv(values.size)
216
+
217
+ # @param value [Numeric, nil]
218
+ # @return [String]
219
+ def percent(value) = value.nil? ? "—" : format("%.1f%%", value * 100)
220
+
221
+ # @param value [Numeric]
222
+ # @return [String]
223
+ def money(value)
224
+ cents = (value.to_s.to_r * 100).ceil
225
+ format("$%<dollars>d.%<cents>02d", dollars: cents / 100, cents: cents % 100)
226
+ end
227
+
228
+ # @param value [Numeric]
229
+ # @return [String]
230
+ def tokens(value) = value.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
231
+
232
+ # @param value [Object]
233
+ # @return [String]
234
+ def pretty(value) = ::JSON.pretty_generate(value)
235
+
236
+ # @param value [Object]
237
+ # @return [String] HTML-escaped text
238
+ def escape(value) = CGI.escapeHTML(value.to_s)
239
+
240
+ # @return [String]
241
+ def stylesheet = asset("html_report.css")
242
+
243
+ def stylesheet_tag
244
+ stylesheet_path ? %(<link rel="stylesheet" href="#{escape(stylesheet_path)}">) : "<style>#{stylesheet}</style>"
245
+ end
246
+
247
+ # @return [String]
248
+ def sorting_script = asset("html_report.js")
249
+
250
+ def script_tag
251
+ script_path ? %(<script src="#{escape(script_path)}"></script>) : "<script>#{sorting_script}</script>"
252
+ end
253
+
254
+ # Reads one packaged asset for embedding in the standalone report.
255
+ #
256
+ # @param name [String] report asset filename
257
+ # @return [String]
258
+ def asset(name) = File.read(File.join(TEMPLATE_DIRECTORY, name))
259
+
260
+ # @return [String]
261
+ def template_path = File.join(TEMPLATE_DIRECTORY, "html_report.html.erb")
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module LLM
7
+ module Evals
8
+ module Render
9
+ module Reports
10
+ # A portable snapshot of completed runs, suitable for rendering later
11
+ # without repeating model calls or depending on the original case files.
12
+ class JSON
13
+ FORMAT = "llm-evals-report"
14
+ VERSION = 1
15
+ Loaded = Data.define(:suite, :generated_at, :run_options, :summaries, :metrics, :weighted_score)
16
+ SavedExperiment = Data.define(:model)
17
+
18
+ # @param summaries [Array<Experiment::Summary>]
19
+ # @param metrics [Array<Metric>]
20
+ # @param weighted_score [WeightedScore, nil]
21
+ # @return [String]
22
+ def self.dump(summaries, metrics:, weighted_score: nil, run_options: {})
23
+ document = {
24
+ format: FORMAT,
25
+ version: VERSION,
26
+ suite: LLM::Evals.configuration.name,
27
+ generated_at: Time.now.iso8601,
28
+ run_options: json_value(run_options),
29
+ metrics: metrics.map { { name: it.name, description: it.description, aggregate: it.aggregate, primary: it.primary } },
30
+ weights: weighted_score&.weights,
31
+ summaries: summaries.map { summary(it) }
32
+ }
33
+ ::JSON.pretty_generate(document)
34
+ end
35
+
36
+ def self.json_value(value)
37
+ case value
38
+ when Hash then value.to_h { |key, item| [key.to_s, json_value(item)] }
39
+ when Array then value.map { json_value(it) }
40
+ when Symbol then value.to_s
41
+ else value
42
+ end
43
+ end
44
+ private_class_method :json_value
45
+
46
+ # @param source [String] report JSON, not a path
47
+ # @param providers [Providers::Registry]
48
+ # @return [Loaded]
49
+ # @raise [Error] for an unknown format/version or provider
50
+ def self.decode(source, providers:)
51
+ document = ::JSON.parse(source)
52
+ unless document["format"] == FORMAT && document["version"] == VERSION
53
+ raise Error, "unsupported report format version (expected #{FORMAT} v#{VERSION})"
54
+ end
55
+
56
+ metrics = document.fetch("metrics").map do |metric|
57
+ Metric.new(name: metric.fetch("name"),
58
+ description: metric.fetch("description", ""),
59
+ aggregate: metric.fetch("aggregate", "mean").to_sym,
60
+ primary: metric.fetch("primary", false))
61
+ end
62
+ weights = document["weights"]
63
+ weighted_score = WeightedScore.new(weights, metrics: metrics.map(&:name)) if weights && !weights.empty?
64
+ Loaded.new(suite: document.fetch("suite", "llm-evals"),
65
+ generated_at: document["generated_at"],
66
+ run_options: document.fetch("run_options", {}),
67
+ summaries: document.fetch("summaries").map { summary_from(it, providers) },
68
+ metrics:,
69
+ weighted_score:)
70
+ rescue ::JSON::ParserError => e
71
+ raise Error, "invalid report JSON: #{e.message}"
72
+ end
73
+
74
+ def self.load(source, providers:) = decode(source, providers:)
75
+
76
+ def self.summary(summary)
77
+ {
78
+ model: { id: summary.model.id, provider: summary.model.provider.name, settings: summary.model.settings },
79
+ dataset: summary.dataset,
80
+ name: summary.name,
81
+ means: summary.means,
82
+ errors: summary.errors,
83
+ duration: summary.duration,
84
+ permalink: summary.permalink,
85
+ usage: summary.usage.to_h,
86
+ results: summary.results.map { result(it) }
87
+ }
88
+ end
89
+ private_class_method :summary
90
+
91
+ def self.result(result)
92
+ {
93
+ case: {
94
+ id: result.case.id,
95
+ dataset: result.case.dataset,
96
+ variant: result.case.variant,
97
+ input: result.case.input,
98
+ expected: result.case.expected,
99
+ metadata: result.case.metadata
100
+ },
101
+ reply: result.reply && {
102
+ output: result.reply.output,
103
+ raw: result.reply.raw,
104
+ usage: result.reply.usage.to_h,
105
+ error: result.reply.error,
106
+ metadata: result.reply.metadata
107
+ },
108
+ score: result.score && {
109
+ value: result.score.value,
110
+ metrics: result.score.metrics,
111
+ outcomes: result.score.outcomes,
112
+ diagnostics: result.score.diagnostics
113
+ },
114
+ duration: result.duration,
115
+ error: result.error
116
+ }
117
+ end
118
+ private_class_method :result
119
+
120
+ def self.summary_from(data, providers)
121
+ model_data = data.fetch("model")
122
+ provider = providers.find { it.name == model_data.fetch("provider") }
123
+ raise Error, "report uses unknown provider #{model_data.fetch("provider").inspect}" unless provider
124
+
125
+ model = Model.new(id: model_data.fetch("id"), provider:, settings: model_data.fetch("settings", {}))
126
+ placeholder_cases = data.fetch("results").map do |result_data|
127
+ case_data = result_data.fetch("case")
128
+ kase = Case.new(id: case_data.fetch("id"),
129
+ dataset: case_data.fetch("dataset"),
130
+ variant: case_data.fetch("variant"),
131
+ input: case_data.fetch("input"),
132
+ expected: case_data["expected"],
133
+ path: "",
134
+ metadata: (case_data["metadata"] || {}).transform_keys(&:to_sym))
135
+ [result_data, kase]
136
+ end
137
+ experiment = SavedExperiment.new(model:)
138
+ results = placeholder_cases.map do |result_data, kase|
139
+ reply_data = result_data["reply"]
140
+ reply = if reply_data
141
+ Reply.new(output: reply_data["output"],
142
+ raw: reply_data["raw"],
143
+ usage: reply_data["usage"],
144
+ error: reply_data["error"],
145
+ metadata: reply_data["metadata"] || {})
146
+ end
147
+ score_data = result_data["score"]
148
+ if score_data
149
+ score = Score.new(value: score_data.fetch("value"),
150
+ metrics: score_data["metrics"] || {},
151
+ outcomes: score_data["outcomes"] || {},
152
+ diagnostics: score_data["diagnostics"] || {})
153
+ end
154
+ RunResult.new(experiment:, case: kase, reply:, score:, duration: result_data.fetch("duration", 0.0), error: result_data["error"])
155
+ end
156
+
157
+ Experiment::Summary.new(model:,
158
+ dataset: data.fetch("dataset"),
159
+ name: data.fetch("name"),
160
+ means: data.fetch("means"),
161
+ results:,
162
+ errors: data.fetch("errors", []),
163
+ duration: data.fetch("duration", 0.0),
164
+ permalink: data["permalink"],
165
+ usage: Usage.from(data["usage"]))
166
+ end
167
+ private_class_method :summary_from
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end