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
@@ -1,52 +1,153 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+
3
7
  module LLM
4
8
  module Evals
5
9
  module Providers
6
10
  # TypeSafe's own API, which serves Jev as typed questions (noul, choice,
7
11
  # 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
12
+ # a `typesafe:` section, whose ids read "typesafe/<name>". Jev on
13
+ # OpenRouter, `typesafe/jev-router` under `openrouter:`, is a chat model
10
14
  # 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]`.
15
+ # A suite's adapter reaches it through {Models::Model#ask}.
14
16
  class TypeSafe < Base
17
+ # The namespace of this API's model ids.
15
18
  PREFIX = "typesafe/"
19
+ # The model list, which {#catalog} reads; {Client::URL} takes the questions.
16
20
  URL = "https://api.typesafe.ai/v1/models"
21
+ # The environment variable holding the API key.
17
22
  KEY = "TYPESAFE_API_KEY"
18
23
 
19
24
  # TypeSafe bills input tokens only: $0.042 per million for jev-latest
20
25
  # and jev-preview (docs.typesafe.ai/models, read 2026-10-14). Output is free.
21
26
  USD_PER_INPUT_TOKEN = 0.042 / 1_000_000
22
27
 
28
+ # An answer TypeSafe refused, with its status, for {Models::Throttle} to judge.
29
+ class HTTPError < Error
30
+ attr_reader :status, :retry_after
31
+
32
+ # @!attribute [r] status
33
+ # @return [Integer] the HTTP status code
34
+
35
+ # @!attribute [r] retry_after
36
+ # @return [Float, nil] seconds from the `retry-after` header, when it is numeric
37
+
38
+ # Reads the status and hint off the response, and keeps at most 500 characters
39
+ # of the body in the message.
40
+ #
41
+ # @param response [Net::HTTPResponse]
42
+ def initialize(response)
43
+ @status = response.code.to_i
44
+ @retry_after = Float(response["retry-after"], exception: false) if response["retry-after"]
45
+ super("TypeSafe answered #{@status}: #{response.body.to_s[0, 500]}")
46
+ end
47
+ end
48
+
49
+ # Overrides {Base#name}.
50
+ #
51
+ # @return [String] "typesafe"
23
52
  def name = "typesafe"
53
+ # Overrides {Base#request_type}: only suites declaring `typesafe` admit this.
54
+ #
55
+ # @return [Symbol] `:typesafe`
24
56
  def request_type = :typesafe
57
+ # Overrides {Base#request_type_description}.
58
+ #
59
+ # @return [String]
25
60
  def request_type_description = "typed questions: noul, choice, score"
61
+ # Overrides {Base#direct?}: reached only through a `typesafe:` section.
62
+ #
63
+ # @return [Boolean] true
26
64
  def direct? = true
65
+ # Overrides {Base#prefix}.
66
+ #
67
+ # @return [String] {PREFIX}
27
68
  def prefix = PREFIX
69
+ # Overrides {Base#env_keys}.
70
+ #
71
+ # @return [Array<String>] just {KEY}
28
72
  def env_keys = [KEY]
29
73
 
30
74
  # Jev takes no reasoning effort.
75
+ #
76
+ # @return [Array<String>] always empty
31
77
  def efforts = []
32
78
 
79
+ # Overrides {Base#catalog}, with the key as a bearer token.
80
+ #
33
81
  # @return [Array<String>] the models, as "typesafe/<name>"
82
+ # @raise [Error] when the list cannot be read
83
+ # @raise [KeyError] when the body has no `models`, or an entry no `name`
34
84
  def catalog
35
85
  Catalog.fetch(URL, "Authorization" => "Bearer #{ENV.fetch(KEY, nil)}").fetch("models").map { "#{PREFIX}#{it.fetch("name")}" }
36
86
  end
37
87
 
38
- # @param input_tokens [Integer]
39
- # @return [Usage] with the cost at TypeSafe's input rate
88
+ # Prices a call at TypeSafe's input rate; output tokens are counted but free.
89
+ #
90
+ # @param input_tokens [Integer] tokens billed
91
+ # @param output_tokens [Integer] tokens returned, which cost nothing
92
+ # @return [Stats::Usage] with the cost at TypeSafe's input rate
40
93
  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)
94
+ Stats::Usage.new(input_tokens: input_tokens, output_tokens: output_tokens, cost_usd: input_tokens * USD_PER_INPUT_TOKEN)
42
95
  end
43
96
 
44
- # @raise [Error] always: there is no typesafe consumer yet
97
+ # Overrides {Base#complete} only to refuse, naming the method to call instead.
98
+ #
99
+ # @param model [Models::Model] the model that was called
100
+ # @param system [String, nil] ignored
101
+ # @param user [String] ignored
102
+ # @param json [Boolean] ignored
103
+ # @return [void] never returns
104
+ # @raise [Error] always: this API answers typed questions; see {#ask}
45
105
  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]"
106
+ raise Error, "#{model.id}: TypeSafe answers typed questions, not prompts; call Model#ask"
49
107
  end
108
+
109
+ # One request of typed questions over one state. They run in parallel
110
+ # and cannot see each other's answers.
111
+ #
112
+ # @param model [Models::Model] the model to ask
113
+ # @param state [Object] what the questions are about; any JSON-encodable value
114
+ # @param questions [Hash{String => Hash}] by name: type, instructions, criteria
115
+ # @return [Results::ProviderResponse] `raw` holds the answers by name; `text` is them as JSON
116
+ # @raise [HTTPError] on any status but 2xx
117
+ # @raise [Error] on a missing key, or a 2xx that is not a usable answer
118
+ def ask(model, state:, questions:)
119
+ body = client.ask({ model: model.name, state: state, questions: questions })
120
+ answers = body["answers"]
121
+ tokens = body.fetch("usage", {})
122
+ Results::ProviderResponse.new(text: JSON.generate(answers),
123
+ usage: usage(input_tokens: tokens["input_tokens"].to_i, output_tokens: tokens["output_tokens"].to_i),
124
+ raw: answers)
125
+ end
126
+
127
+ # Overrides {Base#retryable?}.
128
+ #
129
+ # @param error [StandardError] what a call raised
130
+ # @return [Boolean] 429, 529 and other server errors, and dropped connections
131
+ def retryable?(error)
132
+ case error
133
+ when HTTPError then error.status == 429 || error.status >= 500
134
+ when Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError then true
135
+ else false
136
+ end
137
+ end
138
+
139
+ # Overrides {Base#retry_after}.
140
+ #
141
+ # @param error [StandardError] what a call raised
142
+ # @return [Numeric, nil] the header's seconds from an {HTTPError}; otherwise nil
143
+ def retry_after(error) = error.is_a?(HTTPError) ? error.retry_after : nil
144
+
145
+ private
146
+
147
+ # Built on first use and reused.
148
+ #
149
+ # @return [Client]
150
+ def client = @client ||= Client.new
50
151
  end
51
152
  end
52
153
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # Who answers a model's calls: one class per API, and the registry that picks among them.
6
+ module Providers
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Render
6
+ # The saved snapshot's presentations: JSON, HTML, terminal, and the pipeline joining them.
7
+ module Reports
8
+ # {Stats::CallStats} as the cells of a table, which the caller draws.
9
+ class CallStatsTable
10
+ # The table's column titles, in the order {#rows} fills them.
11
+ HEADER = ["calls by provider, vendor, model", "requests", "errors", "req/s", "avg", "p95"].freeze
12
+ # What a cell shows for a value that is missing.
13
+ EMPTY = "-"
14
+
15
+ # Formats only; the numbers stay {Stats::CallStats}'s to work out.
16
+ #
17
+ # @param stats [Stats::CallStats]
18
+ def initialize(stats)
19
+ @stats = stats
20
+ end
21
+
22
+ # The name and counts stay as they are; the rate and timings become Strings.
23
+ #
24
+ # @return [Array<Array>] one per {Stats::CallStats::Row}, in {HEADER}'s order
25
+ def rows
26
+ @stats.rows.map do |row|
27
+ [row.name, row.requests, row.errors, rate(row.per_second), seconds(row.latency), seconds(row.p95)]
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # @param value [Float, nil] requests per second
34
+ # @return [String] two decimals, or {EMPTY}
35
+ def rate(value) = value ? format("%.2f", value) : EMPTY
36
+
37
+ # @param value [Float, nil] seconds
38
+ # @return [String] two decimals with an "s", or {EMPTY}
39
+ def seconds(value) = value ? format("%.2fs", value) : EMPTY
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Render
6
+ module Reports
7
+ class HTML
8
+ # One dataset's section of the page: one entry per experiment, and its cases.
9
+ class DatasetSection
10
+ # Does nothing until {#to_h}.
11
+ #
12
+ # @param name [String]
13
+ # @param summaries [Array<Actions::Experiment::Summary>] the dataset's
14
+ # @param ranking [Ranking]
15
+ def initialize(name, summaries, ranking)
16
+ @name = name
17
+ @summaries = summaries
18
+ @ranking = ranking
19
+ end
20
+
21
+ # Ranks only the experiments that covered every case of this dataset.
22
+ #
23
+ # @return [Hash{Symbol => Object}] data for the section
24
+ def to_h
25
+ case_keys = results.map { it.case.key }.uniq
26
+ entries = @summaries.map { @ranking.entry([it], case_keys) }
27
+ complete = entries.select { it[:complete] }
28
+ @ranking.rank(complete)
29
+ { name: @name, summaries: @summaries, case_keys:,
30
+ winner: complete.max_by { it[:ranking] || -1 },
31
+ entries: entries.sort_by { it[:ranking] || -1 }.reverse,
32
+ cases: first_run_of_each_case }
33
+ end
34
+
35
+ private
36
+
37
+ # @return [Array<Results::RunResult>] every experiment's runs in this dataset
38
+ def results = @summaries.flat_map(&:results)
39
+
40
+ # Each case appears once on the page, so its input and expected answer come from its first run.
41
+ #
42
+ # @return [Array<Results::RunResult>] ordered by variant, number and id
43
+ def first_run_of_each_case
44
+ results.group_by { it.case.key }.values.map(&:first).sort_by { [it.case.variant, it.case.number || 0, it.case.id] }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Render
6
+ module Reports
7
+ class HTML
8
+ # How the report's page prints numbers. Not {CLI::Formatting}: a page shows
9
+ # a missing value as an em dash, and rounds a cost up to the cent.
10
+ module Format
11
+ private
12
+
13
+ # Scales a share of 1 to 100%, and shows a missing value as an em dash.
14
+ #
15
+ # @param value [Numeric, nil]
16
+ # @return [String]
17
+ def percent(value) = value.nil? ? "—" : format("%.1f%%", value * 100)
18
+
19
+ # Goes through Rational and rounds up, so float noise cannot change a cost and a cost is never understated.
20
+ #
21
+ # @param value [Numeric]
22
+ # @return [String]
23
+ def money(value)
24
+ cents = (value.to_s.to_r * 100).ceil
25
+ format("$%<dollars>d.%<cents>02d", dollars: cents / 100, cents: cents % 100)
26
+ end
27
+
28
+ # Groups the digits with commas; fractions are dropped.
29
+ #
30
+ # @param value [Numeric]
31
+ # @return [String]
32
+ def tokens(value) = value.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Render
6
+ module Reports
7
+ class HTML
8
+ # The page's overall ranking: one entry per model at one setting, over
9
+ # every dataset. Only complete contenders are ranked.
10
+ class GlobalStandings
11
+ # Does nothing until {#to_h}.
12
+ #
13
+ # @param summaries [Array<Actions::Experiment::Summary>]
14
+ # @param ranking [Ranking]
15
+ def initialize(summaries, ranking)
16
+ @summaries = summaries
17
+ @ranking = ranking
18
+ end
19
+
20
+ # Coverage is judged against every case key seen in the report, across all datasets.
21
+ #
22
+ # @return [Hash{Symbol => Object}] report entries and ranking after coverage checks
23
+ def to_h
24
+ expected_keys = @summaries.flat_map(&:results).map { it.case.key }.uniq
25
+ entries = groups.map { @ranking.entry(it, expected_keys) }
26
+ complete = entries.select { it[:complete] }
27
+ @ranking.rank(complete)
28
+ ranked = complete.sort_by { it[:ranking] || -1 }
29
+ { entries:, expected_count: expected_keys.length, ranked:, leader: ranked.last, best_coverage: entries.max_by { it[:successful] } }
30
+ end
31
+
32
+ private
33
+
34
+ # @return [Array<Array<Actions::Experiment::Summary>>] one group per model at one setting
35
+ def groups = @summaries.group_by { [it.model.key, it.model.effort, it.model.temperature] }.values
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Render
6
+ module Reports
7
+ class HTML
8
+ # How the page measures a contender and ranks a group of them. Standings
9
+ # and dataset sections differ in what they group, never in this.
10
+ class Ranking
11
+ # Without a score, or after any failure, a result has nothing to contribute.
12
+ #
13
+ # @param result [Results::RunResult]
14
+ # @return [Boolean, nil] whether the result counts for nothing; nil, which is falsy, for a scored run with no reply
15
+ def self.failed?(result) = !result.score || result.failed? || result.reply&.error?
16
+
17
+ # Holds no per-group state, so standings and sections can share one.
18
+ #
19
+ # @param metrics [Array<Scoring::Metric>]
20
+ # @param weighted_score [Scoring::WeightedScore, nil]
21
+ def initialize(metrics:, weighted_score:)
22
+ @metrics = metrics
23
+ @weighted_score = weighted_score
24
+ end
25
+
26
+ # The metric marked primary, or "score" when none is; what ranks without a weighted score.
27
+ #
28
+ # @return [String]
29
+ def primary_metric = @metrics.find(&:primary)&.name || "score"
30
+
31
+ # Measures one contender. Complete means every expected case succeeded with no failures,
32
+ # and only a complete contender may be ranked. Repeat runs of a case count once.
33
+ #
34
+ # @param group [Array<Actions::Experiment::Summary>]
35
+ # @param expected_keys [Array<String>]
36
+ # @return [Hash{Symbol => Object}] model coverage and score inputs
37
+ def entry(group, expected_keys)
38
+ results = group.flat_map(&:results).uniq { it.case.key }
39
+ failures = results.count { self.class.failed?(it) }
40
+ successful_keys = results.reject { self.class.failed?(it) }.map { it.case.key }.uniq
41
+ {
42
+ model: group.first.model,
43
+ successful: successful_keys.length,
44
+ total: expected_keys.length,
45
+ errors: failures,
46
+ cost: group.sum { it.usage.cost_usd },
47
+ input_tokens: group.sum { it.usage.input_tokens },
48
+ output_tokens: group.sum { it.usage.output_tokens },
49
+ row: row(group, results, failures),
50
+ complete: expected_keys.length.positive? && successful_keys.sort == expected_keys.uniq.sort && failures.zero?
51
+ }
52
+ end
53
+
54
+ # Records each entry's :ranking, compared within the entries given.
55
+ #
56
+ # @param entries [Array<Hash{Symbol => Object}>]
57
+ # @return [void]
58
+ def rank(entries)
59
+ rows = entries.map { it[:row] }
60
+ scores = @weighted_score ? @weighted_score.call(rows) : rows.map { it[primary_metric] }
61
+ entries.zip(scores).each { |entry, score| entry[:ranking] = score }
62
+ end
63
+
64
+ private
65
+
66
+ # Means per metric, plus the columns {Scoring::WeightedScore} reads besides.
67
+ #
68
+ # @param group [Array<Actions::Experiment::Summary>] the contender's experiments
69
+ # @param results [Array<Results::RunResult>] its runs, one per case
70
+ # @param failures [Integer] how many of those failed
71
+ # @return [Hash{String => Numeric, nil}] by metric name, plus "score", "errors", "time" and "cost"
72
+ def row(group, results, failures)
73
+ metric_means = @metrics.to_h { |metric| [metric.name, mean(results.filter_map { it.score&.metrics&.fetch(metric.name, nil) })] }
74
+ scores = results.filter_map { it.score&.value unless it.failed? || it.reply&.error? }
75
+ metric_means.merge("score" => mean(scores), "errors" => failures, "time" => group.sum(&:duration), "cost" => group.sum { it.usage.cost_usd })
76
+ end
77
+
78
+ # @param values [Array<Numeric>]
79
+ # @return [Float, nil] nil with no values
80
+ def mean(values) = values.empty? ? nil : values.sum.fdiv(values.size)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Render
6
+ module Reports
7
+ class HTML
8
+ # What the report's page shows, worked out from a decoded snapshot; the
9
+ # template and its markup stay with {HTML}.
10
+ class ViewModel
11
+ include Format
12
+
13
+ # @return [JSON::Loaded] the decoded snapshot behind every answer here
14
+ attr_reader :report
15
+
16
+ # Shares one {Ranking} between the standings and every dataset section.
17
+ #
18
+ # @param report [JSON::Loaded]
19
+ def initialize(report)
20
+ @report = report
21
+ @ranking = Ranking.new(metrics: report.metrics, weighted_score: report.weighted_score)
22
+ end
23
+
24
+ # The page's heading.
25
+ #
26
+ # @return [String]
27
+ def report_title = "#{configuration_name} evaluation report"
28
+
29
+ # The suite's name, as the snapshot recorded it.
30
+ #
31
+ # @return [String]
32
+ def configuration_name = report.suite
33
+
34
+ # @return [Array<Actions::Experiment::Summary>] every experiment in the snapshot
35
+ def summaries = report.summaries
36
+
37
+ # @return [Scoring::WeightedScore, nil] nil when the lineup set no weights
38
+ def weighted_score = report.weighted_score
39
+
40
+ # Across every result in the report, with tokens and cost already formatted for display.
41
+ #
42
+ # @return [Hash{Symbol => Object}] health counts and cost for the template
43
+ def health_summary
44
+ usage = summaries.map(&:usage).reduce(Stats::Usage.zero, :+)
45
+ failed = results.count { Ranking.failed?(it) }
46
+ { successful: results.size - failed,
47
+ total: results.size,
48
+ failed:,
49
+ input_tokens: tokens(usage.input_tokens),
50
+ output_tokens: tokens(usage.output_tokens),
51
+ cost: money(usage.cost_usd) }
52
+ end
53
+
54
+ # The whole page's ranking, built fresh on each call.
55
+ #
56
+ # @return [Hash{Symbol => Object}] see {GlobalStandings#to_h}
57
+ def global_standings = GlobalStandings.new(summaries, @ranking).to_h
58
+
59
+ # One section per dataset, in name order.
60
+ #
61
+ # @return [Array<Hash{Symbol => Object}>] see {DatasetSection#to_h}
62
+ def dataset_reports
63
+ summaries.group_by(&:dataset).sort.map { |name, group| DatasetSection.new(name, group, @ranking).to_h }
64
+ end
65
+
66
+ # Details prefer the error of a run that raised over its score's diagnostics.
67
+ #
68
+ # @param kase [Datasets::Case]
69
+ # @param group [Array<Actions::Experiment::Summary>]
70
+ # @return [Array<Hash{Symbol => Object}>] runs and display values for one case
71
+ def case_results(kase, group)
72
+ group.flat_map(&:results).select { it.case.key == kase.key }.map do |run|
73
+ details = run.failed? ? { "error" => run.error } : run.score&.diagnostics
74
+ { run:, status: status(run), details: details || {} }
75
+ end
76
+ end
77
+
78
+ # Hides the entry's shape from the template.
79
+ #
80
+ # @param entry [Hash{Symbol => Object}]
81
+ # @return [Float, nil]
82
+ def ranking_value(entry) = entry[:ranking]
83
+
84
+ # The name of what ranks when no weighted score does.
85
+ #
86
+ # @return [String]
87
+ def primary_metric = @ranking.primary_metric
88
+
89
+ # The sentence under the standings explaining how they were ordered.
90
+ #
91
+ # @return [String]
92
+ def weight_description
93
+ weighted_score ? "Weighted score from the lineup; chart order runs worst to best." : "Ranked by #{primary_metric}; chart order runs worst to best."
94
+ end
95
+
96
+ private
97
+
98
+ # @return [Array<Results::RunResult>] every run in the report
99
+ def results = summaries.flat_map(&:results)
100
+
101
+ # The run's error, the model's failure, its percentage, or "No score".
102
+ #
103
+ # @param run [Results::RunResult]
104
+ # @return [String]
105
+ def status(run)
106
+ return run.error if run.failed?
107
+ return run.reply.error if run.reply&.error?
108
+
109
+ run.score ? percent(run.score.value) : "No score"
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end