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
|
@@ -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 `
|
|
9
|
-
# OpenRouter, `typesafe/jev-router` under `
|
|
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
|
-
#
|
|
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
|
-
#
|
|
39
|
-
#
|
|
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
|
-
#
|
|
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,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
|