llm-evals 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +163 -87
  4. data/exe/llm-evals +60 -5
  5. data/lib/.DS_Store +0 -0
  6. data/lib/llm/.DS_Store +0 -0
  7. data/lib/llm/evals/actions/adapter.rb +50 -0
  8. data/lib/llm/evals/actions/error_budget.rb +63 -0
  9. data/lib/llm/evals/actions/error_diagnosis.rb +51 -0
  10. data/lib/llm/evals/actions/error_streak.rb +83 -0
  11. data/lib/llm/evals/actions/experiment.rb +251 -0
  12. data/lib/llm/evals/actions/verify.rb +61 -0
  13. data/lib/llm/evals/actions.rb +9 -0
  14. data/lib/llm/evals/cli/cases.rb +10 -0
  15. data/lib/llm/evals/cli/command.rb +32 -99
  16. data/lib/llm/evals/cli/compare.rb +0 -20
  17. data/lib/llm/evals/cli/contender_selector.rb +61 -0
  18. data/lib/llm/evals/cli/error_pause.rb +81 -0
  19. data/lib/llm/evals/cli/experiment_pool.rb +97 -0
  20. data/lib/llm/evals/cli/formatting.rb +8 -0
  21. data/lib/llm/evals/cli/leaderboard.rb +66 -12
  22. data/lib/llm/evals/cli/model_colors.rb +3 -0
  23. data/lib/llm/evals/cli/model_command.rb +152 -315
  24. data/lib/llm/evals/cli/model_options.rb +80 -0
  25. data/lib/llm/evals/cli/option_groups.rb +32 -0
  26. data/lib/llm/evals/cli/progress_bars.rb +92 -5
  27. data/lib/llm/evals/cli/render.rb +6 -6
  28. data/lib/llm/evals/cli/report_dispatcher/console.rb +65 -0
  29. data/lib/llm/evals/cli/report_dispatcher/html_output.rb +50 -0
  30. data/lib/llm/evals/cli/report_dispatcher/json_output.rb +29 -0
  31. data/lib/llm/evals/cli/report_dispatcher/output.rb +56 -0
  32. data/lib/llm/evals/cli/report_dispatcher/pipeline_output.rb +38 -0
  33. data/lib/llm/evals/cli/report_dispatcher.rb +133 -0
  34. data/lib/llm/evals/cli/run.rb +13 -22
  35. data/lib/llm/evals/cli/run_planner.rb +97 -0
  36. data/lib/llm/evals/cli/run_record.rb +68 -0
  37. data/lib/llm/evals/cli/standings.rb +39 -17
  38. data/lib/llm/evals/cli/status_box.rb +51 -0
  39. data/lib/llm/evals/cli/table.rb +103 -0
  40. data/lib/llm/evals/cli/text.rb +22 -0
  41. data/lib/llm/evals/cli/tracker_resolver.rb +52 -0
  42. data/lib/llm/evals/cli/upgrade_config.rb +60 -0
  43. data/lib/llm/evals/cli/verify.rb +5 -1
  44. data/lib/llm/evals/cli/version.rb +4 -0
  45. data/lib/llm/evals/cli/weights.rb +48 -0
  46. data/lib/llm/evals/cli.rb +39 -6
  47. data/lib/llm/evals/config/contender_builder.rb +30 -0
  48. data/lib/llm/evals/config/error_limits.rb +41 -0
  49. data/lib/llm/evals/config/listing.rb +153 -0
  50. data/lib/llm/evals/config/results.rb +42 -0
  51. data/lib/llm/evals/config/score_weights.rb +25 -0
  52. data/lib/llm/evals/config/upgrade.rb +107 -0
  53. data/lib/llm/evals/config.rb +90 -79
  54. data/lib/llm/evals/datasets/case.rb +46 -0
  55. data/lib/llm/evals/datasets/discovery.rb +143 -0
  56. data/lib/llm/evals/datasets.rb +9 -0
  57. data/lib/llm/evals/launcher.rb +57 -3
  58. data/lib/llm/evals/models/contender.rb +51 -0
  59. data/lib/llm/evals/models/model.rb +87 -0
  60. data/lib/llm/evals/models/throttle.rb +170 -0
  61. data/lib/llm/evals/models.rb +9 -0
  62. data/lib/llm/evals/providers/base.rb +61 -16
  63. data/lib/llm/evals/providers/catalog.rb +5 -2
  64. data/lib/llm/evals/providers/open_router.rb +54 -7
  65. data/lib/llm/evals/providers/registry.rb +49 -16
  66. data/lib/llm/evals/providers/type_safe/client.rb +67 -0
  67. data/lib/llm/evals/providers/type_safe.rb +113 -12
  68. data/lib/llm/evals/providers.rb +9 -0
  69. data/lib/llm/evals/render/reports/call_stats_table.rb +44 -0
  70. data/lib/llm/evals/render/reports/html/dataset_section.rb +51 -0
  71. data/lib/llm/evals/render/reports/html/format.rb +38 -0
  72. data/lib/llm/evals/render/reports/html/global_standings.rb +41 -0
  73. data/lib/llm/evals/render/reports/html/ranking.rb +86 -0
  74. data/lib/llm/evals/render/reports/html/view_model.rb +116 -0
  75. data/lib/llm/evals/render/reports/html.rb +60 -181
  76. data/lib/llm/evals/render/reports/json/decoder.rb +137 -0
  77. data/lib/llm/evals/render/reports/json/encoder.rb +106 -0
  78. data/lib/llm/evals/render/reports/json.rb +38 -141
  79. data/lib/llm/evals/render/reports/pipeline.rb +55 -20
  80. data/lib/llm/evals/render/reports/terminal.rb +8 -1
  81. data/lib/llm/evals/render.rb +9 -0
  82. data/lib/llm/evals/results/provider_response.rb +25 -0
  83. data/lib/llm/evals/results/reply.rb +38 -0
  84. data/lib/llm/evals/results/run_outcome.rb +15 -0
  85. data/lib/llm/evals/results/run_result.rb +40 -0
  86. data/lib/llm/evals/results/score.rb +45 -0
  87. data/lib/llm/evals/results/warnings.rb +149 -0
  88. data/lib/llm/evals/results.rb +9 -0
  89. data/lib/llm/evals/runtime/configuration.rb +295 -0
  90. data/lib/llm/evals/runtime/environment.rb +90 -0
  91. data/lib/llm/evals/runtime/helper.rb +153 -0
  92. data/lib/llm/evals/runtime.rb +2 -67
  93. data/lib/llm/evals/scoring/metric.rb +47 -0
  94. data/lib/llm/evals/scoring/schema_validator.rb +102 -0
  95. data/lib/llm/evals/scoring/scorer.rb +49 -0
  96. data/lib/llm/evals/scoring/weighted_score.rb +148 -0
  97. data/lib/llm/evals/scoring.rb +9 -0
  98. data/lib/llm/evals/stats/call_stats.rb +91 -0
  99. data/lib/llm/evals/stats/usage.rb +46 -0
  100. data/lib/llm/evals/stats.rb +29 -0
  101. data/lib/llm/evals/testing/echo_adapter.rb +14 -9
  102. data/lib/llm/evals/testing/fake_model.rb +8 -6
  103. data/lib/llm/evals/testing/fake_provider.rb +39 -8
  104. data/lib/llm/evals/testing.rb +9 -0
  105. data/lib/llm/evals/trackers/base.rb +110 -0
  106. data/lib/llm/evals/trackers/braintrust.rb +40 -3
  107. data/lib/llm/evals/trackers/local.rb +38 -2
  108. data/lib/llm/evals/trackers/logfire.rb +56 -3
  109. data/lib/llm/evals/trackers/registry.rb +29 -7
  110. data/lib/llm/evals/trackers.rb +9 -0
  111. data/lib/llm/evals/version.rb +3 -1
  112. data/lib/llm/evals.rb +17 -7
  113. metadata +91 -24
  114. data/lib/llm/evals/adapter.rb +0 -47
  115. data/lib/llm/evals/case.rb +0 -36
  116. data/lib/llm/evals/completion.rb +0 -11
  117. data/lib/llm/evals/configuration.rb +0 -173
  118. data/lib/llm/evals/contender.rb +0 -31
  119. data/lib/llm/evals/discovery.rb +0 -95
  120. data/lib/llm/evals/experiment.rb +0 -158
  121. data/lib/llm/evals/helper.rb +0 -98
  122. data/lib/llm/evals/metric.rb +0 -26
  123. data/lib/llm/evals/model.rb +0 -55
  124. data/lib/llm/evals/reply.rb +0 -18
  125. data/lib/llm/evals/run_result.rb +0 -14
  126. data/lib/llm/evals/schema_validator.rb +0 -84
  127. data/lib/llm/evals/score.rb +0 -26
  128. data/lib/llm/evals/scorer.rb +0 -45
  129. data/lib/llm/evals/tracker.rb +0 -80
  130. data/lib/llm/evals/usage.rb +0 -33
  131. data/lib/llm/evals/verify.rb +0 -49
  132. data/lib/llm/evals/warnings.rb +0 -98
  133. data/lib/llm/evals/weighted_score.rb +0 -116
  134. /data/lib/llm/evals/{templates → render/templates}/html_report.css +0 -0
  135. /data/lib/llm/evals/{templates → render/templates}/html_report.html.erb +0 -0
  136. /data/lib/llm/evals/{templates → render/templates}/html_report.js +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "cgi"
4
4
  require "erb"
5
+ require "forwardable"
5
6
  require "json"
6
7
 
7
8
  module LLM
@@ -11,18 +12,31 @@ module LLM
11
12
  # Builds a self-contained HTML report for completed eval runs.
12
13
  # Only contenders with full, error-free coverage can lead the report.
13
14
  class HTML
15
+ extend Forwardable
16
+ include Format
17
+
14
18
  # Directory containing the report template and its embedded assets.
15
- TEMPLATE_DIRECTORY = File.expand_path("../../templates", __dir__)
19
+ TEMPLATE_DIRECTORY = File.expand_path("../templates", __dir__)
16
20
 
21
+ # Shorthand for building one and rendering it.
22
+ #
17
23
  # @param report [JSON::Loaded] decoded from a saved JSON snapshot
24
+ # @param stylesheet_path [String, nil] link to this stylesheet; nil embeds it in the page
25
+ # @param script_path [String, nil] link to this script; nil embeds it in the page
18
26
  # @return [String]
19
27
  def self.render(report, stylesheet_path: nil, script_path: nil)
20
28
  new(report, stylesheet_path:, script_path:).render
21
29
  end
22
30
 
31
+ # What the page is built from, and the assets it links instead of embedding.
32
+ # `report` is a {JSON::Loaded}; the two paths are Strings, or nil to embed.
33
+ #
34
+ # @return [JSON::Loaded, String, nil]
23
35
  attr_reader :report, :stylesheet_path, :script_path
24
36
 
25
37
  # @param report [JSON::Loaded] decoded from a saved JSON snapshot
38
+ # @param stylesheet_path [String, nil] link to this stylesheet; nil embeds it in the page
39
+ # @param script_path [String, nil] link to this script; nil embeds it in the page
26
40
  def initialize(report, stylesheet_path: nil, script_path: nil)
27
41
  @report = report
28
42
  @stylesheet_path = stylesheet_path
@@ -33,220 +47,83 @@ module LLM
33
47
  #
34
48
  # @return [String]
35
49
  def render
36
- health = health_summary
37
- standings = global_standings
38
- datasets = dataset_reports
50
+ health = view.health_summary
51
+ standings = view.global_standings
52
+ datasets = view.dataset_reports
39
53
  ERB.new(File.read(template_path), trim_mode: "-").result(binding)
40
54
  end
41
55
 
42
56
  private
43
57
 
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, :+)
58
+ # Template helpers that {ViewModel} answers; the ERB calls them through this binding.
59
+ def_delegators :view,
60
+ :report_title,
61
+ :configuration_name,
62
+ :summaries,
63
+ :weighted_score,
64
+ :case_results,
65
+ :ranking_value,
66
+ :primary_metric,
67
+ :weight_description
68
+
69
+ # The template's source of data, built once per report.
70
+ #
71
+ # @return [ViewModel]
72
+ def view = @view ||= ViewModel.new(report)
197
73
 
198
- # @param model [Model]
74
+ # The id's first segment is called the provider here; an id with no slash fills both.
75
+ #
76
+ # @param model [Models::Model]
199
77
  # @return [Array<String>] HTML-escaped provider and model names
200
78
  def model_parts(model)
201
79
  provider, name = model.id.to_s.split("/", 2)
202
80
  [escape(provider), escape(name || provider)]
203
81
  end
204
82
 
205
- # @param model [Model]
83
+ # For the template's model rows.
84
+ #
85
+ # @param model [Models::Model]
206
86
  # @return [String] escaped effort setting, or `default`
207
- def effort_label(model) = escape(model.effort || "default")
87
+ def effort_label(model) = escape(model.effort_label)
208
88
 
209
- # @param model [Model]
89
+ # For the template's model rows.
90
+ #
91
+ # @param model [Models::Model]
210
92
  # @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
93
+ def temperature_label(model) = escape(model.temperature_label)
231
94
 
95
+ # For the expected answer and case details on the page.
96
+ #
232
97
  # @param value [Object]
233
98
  # @return [String]
234
99
  def pretty(value) = ::JSON.pretty_generate(value)
235
100
 
101
+ # Everything the template prints from data goes through this.
102
+ #
236
103
  # @param value [Object]
237
104
  # @return [String] HTML-escaped text
238
105
  def escape(value) = CGI.escapeHTML(value.to_s)
239
106
 
107
+ # The packaged stylesheet's text, for embedding.
108
+ #
240
109
  # @return [String]
241
110
  def stylesheet = asset("html_report.css")
242
111
 
112
+ # A link when the report ships its stylesheet alongside; otherwise embedded, so the file stands alone.
113
+ #
114
+ # @return [String] an HTML tag
243
115
  def stylesheet_tag
244
116
  stylesheet_path ? %(<link rel="stylesheet" href="#{escape(stylesheet_path)}">) : "<style>#{stylesheet}</style>"
245
117
  end
246
118
 
119
+ # The packaged script's text, which sorts the tables, for embedding.
120
+ #
247
121
  # @return [String]
248
122
  def sorting_script = asset("html_report.js")
249
123
 
124
+ # A link when the report ships its script alongside; otherwise embedded, so the file stands alone.
125
+ #
126
+ # @return [String] an HTML tag
250
127
  def script_tag
251
128
  script_path ? %(<script src="#{escape(script_path)}"></script>) : "<script>#{sorting_script}</script>"
252
129
  end
@@ -257,6 +134,8 @@ module LLM
257
134
  # @return [String]
258
135
  def asset(name) = File.read(File.join(TEMPLATE_DIRECTORY, name))
259
136
 
137
+ # Where the page's ERB source lives.
138
+ #
260
139
  # @return [String]
261
140
  def template_path = File.join(TEMPLATE_DIRECTORY, "html_report.html.erb")
262
141
  end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Render
6
+ module Reports
7
+ class JSON
8
+ # A snapshot's document back into runs, one mapper for each of {Encoder}'s.
9
+ class Decoder
10
+ # Looked up by name for each saved model.
11
+ #
12
+ # @param providers [Providers::Registry]
13
+ def initialize(providers:)
14
+ @providers = providers
15
+ end
16
+
17
+ # Parses a document, checking its format and version before reading anything else.
18
+ #
19
+ # @param source [String] report JSON, not a path
20
+ # @return [Loaded]
21
+ # @raise [KeyError] when a required field is missing
22
+ # @raise [Error] for an unknown format/version or provider
23
+ def call(source)
24
+ document = ::JSON.parse(source)
25
+ unless document["format"] == FORMAT && document["version"] == VERSION
26
+ raise Error, "unsupported report format version (expected #{FORMAT} v#{VERSION})"
27
+ end
28
+
29
+ metrics = document.fetch("metrics").map { metric(it) }
30
+ Loaded.new(suite: document.fetch("suite", "llm-evals"),
31
+ generated_at: document["generated_at"],
32
+ run_options: document.fetch("run_options", {}),
33
+ summaries: document.fetch("summaries").map { summary(it) },
34
+ metrics:,
35
+ weighted_score: weighted_score(document["weights"], metrics))
36
+ rescue ::JSON::ParserError => e
37
+ raise Error, "invalid report JSON: #{e.message}"
38
+ end
39
+
40
+ private
41
+
42
+ # @param data [Hash{String => Object}] one saved metric
43
+ # @return [Scoring::Metric]
44
+ def metric(data)
45
+ Scoring::Metric.new(name: data.fetch("name"),
46
+ description: data.fetch("description", ""),
47
+ aggregate: data.fetch("aggregate", "mean").to_sym,
48
+ primary: data.fetch("primary", false))
49
+ end
50
+
51
+ # Rebuilds the lineup's weights, when it set any.
52
+ #
53
+ # @param weights [Hash{String => Numeric}, nil] as saved
54
+ # @param metrics [Array<Scoring::Metric>] the snapshot's
55
+ # @return [Scoring::WeightedScore, nil] nil with no weights
56
+ def weighted_score(weights, metrics)
57
+ Scoring::WeightedScore.new(weights, metrics: metrics.map(&:name)) if weights && !weights.empty?
58
+ end
59
+
60
+ # One saved experiment, whose results all share a {SavedExperiment} in place of the real one.
61
+ #
62
+ # @param data [Hash{String => Object}] one saved summary
63
+ # @return [Actions::Experiment::Summary]
64
+ def summary(data)
65
+ model = model(data.fetch("model"))
66
+ experiment = SavedExperiment.new(model:)
67
+ Actions::Experiment::Summary.new(model:,
68
+ dataset: data.fetch("dataset"),
69
+ name: data.fetch("name"),
70
+ means: data.fetch("means"),
71
+ results: data.fetch("results").map { result(it, experiment) },
72
+ errors: data.fetch("errors", []),
73
+ duration: data.fetch("duration", 0.0),
74
+ permalink: data["permalink"],
75
+ usage: Stats::Usage.from(data["usage"]))
76
+ end
77
+
78
+ # @param data [Hash{String => Object}] the saved id, provider and settings
79
+ # @return [Models::Model]
80
+ # @raise [Error] when no configured provider has the saved name
81
+ def model(data)
82
+ provider = @providers.find { it.name == data.fetch("provider") }
83
+ raise Error, "report uses unknown provider #{data.fetch("provider").inspect}" unless provider
84
+
85
+ Models::Model.new(id: data.fetch("id"), provider:, settings: data.fetch("settings", {}))
86
+ end
87
+
88
+ # @param data [Hash{String => Object}] one saved run
89
+ # @param experiment [SavedExperiment] what ran it
90
+ # @return [Results::RunResult]
91
+ def result(data, experiment)
92
+ Results::RunResult.new(experiment:,
93
+ case: kase(data.fetch("case")),
94
+ reply: reply(data["reply"]),
95
+ score: score(data["score"]),
96
+ duration: data.fetch("duration", 0.0),
97
+ error: data["error"])
98
+ end
99
+
100
+ # The case's directory is not saved, so its path comes back empty.
101
+ #
102
+ # @param data [Hash{String => Object}] one saved case
103
+ # @return [Datasets::Case]
104
+ def kase(data)
105
+ Datasets::Case.new(id: data.fetch("id"),
106
+ dataset: data.fetch("dataset"),
107
+ variant: data.fetch("variant"),
108
+ input: data.fetch("input"),
109
+ expected: data["expected"],
110
+ path: "",
111
+ metadata: (data["metadata"] || {}).transform_keys(&:to_sym))
112
+ end
113
+
114
+ # @param data [Hash{String => Object}, nil] one saved reply
115
+ # @return [Results::Reply, nil] nil when the run saved none
116
+ def reply(data)
117
+ return unless data
118
+
119
+ Results::Reply.new(output: data["output"], raw: data["raw"], usage: data["usage"], error: data["error"], metadata: data["metadata"] || {})
120
+ end
121
+
122
+ # @param data [Hash{String => Object}, nil] one saved score
123
+ # @return [Results::Score, nil] nil when the run saved none
124
+ def score(data)
125
+ return unless data
126
+
127
+ Results::Score.new(value: data.fetch("value"),
128
+ metrics: data["metrics"] || {},
129
+ outcomes: data["outcomes"] || {},
130
+ diagnostics: data["diagnostics"] || {})
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module LLM
6
+ module Evals
7
+ module Render
8
+ module Reports
9
+ class JSON
10
+ # Completed runs into the snapshot's document. {Decoder} reads it back.
11
+ class Encoder
12
+ # Does nothing until {#call}.
13
+ #
14
+ # @param summaries [Array<Actions::Experiment::Summary>]
15
+ # @param metrics [Array<Scoring::Metric>]
16
+ # @param weighted_score [Scoring::WeightedScore, nil]
17
+ # @param run_options [Hash]
18
+ # @param suite [String]
19
+ def initialize(summaries, metrics:, weighted_score:, run_options:, suite:)
20
+ @summaries = summaries
21
+ @metrics = metrics
22
+ @weighted_score = weighted_score
23
+ @run_options = run_options
24
+ @suite = suite
25
+ end
26
+
27
+ # Stamps the document with the current time. Call stats are included for other
28
+ # readers of the file; {Decoder} ignores them and recalculates.
29
+ #
30
+ # @return [String]
31
+ def call
32
+ ::JSON.pretty_generate(
33
+ format: FORMAT,
34
+ version: VERSION,
35
+ suite: @suite,
36
+ generated_at: Time.now.iso8601,
37
+ run_options: plain(@run_options),
38
+ metrics: @metrics.map { { name: it.name, description: it.description, aggregate: it.aggregate, primary: it.primary } },
39
+ weights: @weighted_score&.weights,
40
+ call_stats: Stats::CallStats.for_run(@summaries, @run_options).rows.map(&:to_h),
41
+ summaries: @summaries.map { summary(it) }
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ # Turns symbols into strings at every depth, so what is written reads back the same.
48
+ #
49
+ # @param value [Object] run options, or anything inside them
50
+ # @return [Object] the same shape, with no Symbols
51
+ def plain(value)
52
+ case value
53
+ when Hash then value.to_h { |key, item| [key.to_s, plain(item)] }
54
+ when Array then value.map { plain(it) }
55
+ when Symbol then value.to_s
56
+ else value
57
+ end
58
+ end
59
+
60
+ # @param summary [Actions::Experiment::Summary]
61
+ # @return [Hash{Symbol => Object}]
62
+ def summary(summary)
63
+ {
64
+ model: { id: summary.model.id, provider: summary.model.provider.name, settings: summary.model.settings },
65
+ dataset: summary.dataset,
66
+ name: summary.name,
67
+ means: summary.means,
68
+ errors: summary.errors,
69
+ duration: summary.duration,
70
+ permalink: summary.permalink,
71
+ usage: summary.usage.to_h,
72
+ results: summary.results.map { result(it) }
73
+ }
74
+ end
75
+
76
+ # @param result [Results::RunResult]
77
+ # @return [Hash{Symbol => Object}]
78
+ def result(result)
79
+ { case: kase(result.case), reply: reply(result.reply), score: score(result.score), duration: result.duration, error: result.error }
80
+ end
81
+
82
+ # Omits the case's path, keeping the snapshot portable.
83
+ #
84
+ # @param kase [Datasets::Case]
85
+ # @return [Hash{Symbol => Object}]
86
+ def kase(kase)
87
+ { id: kase.id, dataset: kase.dataset, variant: kase.variant, input: kase.input, expected: kase.expected, metadata: kase.metadata }
88
+ end
89
+
90
+ # @param reply [Results::Reply, nil]
91
+ # @return [Hash{Symbol => Object}, nil] nil for a run that raised
92
+ def reply(reply)
93
+ reply && { output: reply.output, raw: reply.raw, usage: reply.usage.to_h, error: reply.error, metadata: reply.metadata }
94
+ end
95
+
96
+ # @param score [Results::Score, nil]
97
+ # @return [Hash{Symbol => Object}, nil] nil for an unscored run
98
+ def score(score)
99
+ score && { value: score.value, metrics: score.metrics, outcomes: score.outcomes, diagnostics: score.diagnostics }
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end