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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE.txt +21 -0
- data/README.md +764 -0
- data/exe/evals +9 -0
- data/exe/llm-evals +14 -0
- data/lib/llm/evals/adapter.rb +47 -0
- data/lib/llm/evals/case.rb +36 -0
- data/lib/llm/evals/cli/cases.rb +48 -0
- data/lib/llm/evals/cli/command.rb +163 -0
- data/lib/llm/evals/cli/compare.rb +40 -0
- data/lib/llm/evals/cli/formatting.rb +16 -0
- data/lib/llm/evals/cli/leaderboard.rb +183 -0
- data/lib/llm/evals/cli/model_colors.rb +23 -0
- data/lib/llm/evals/cli/model_command.rb +437 -0
- data/lib/llm/evals/cli/progress_bars.rb +147 -0
- data/lib/llm/evals/cli/render.rb +37 -0
- data/lib/llm/evals/cli/run.rb +69 -0
- data/lib/llm/evals/cli/standings.rb +114 -0
- data/lib/llm/evals/cli/verify.rb +24 -0
- data/lib/llm/evals/cli/version.rb +15 -0
- data/lib/llm/evals/cli.rb +43 -0
- data/lib/llm/evals/completion.rb +11 -0
- data/lib/llm/evals/config.rb +198 -0
- data/lib/llm/evals/configuration.rb +173 -0
- data/lib/llm/evals/contender.rb +31 -0
- data/lib/llm/evals/discovery.rb +95 -0
- data/lib/llm/evals/experiment.rb +158 -0
- data/lib/llm/evals/helper.rb +98 -0
- data/lib/llm/evals/launcher.rb +37 -0
- data/lib/llm/evals/metric.rb +26 -0
- data/lib/llm/evals/model.rb +55 -0
- data/lib/llm/evals/providers/base.rb +93 -0
- data/lib/llm/evals/providers/catalog.rb +32 -0
- data/lib/llm/evals/providers/open_router.rb +64 -0
- data/lib/llm/evals/providers/registry.rb +112 -0
- data/lib/llm/evals/providers/type_safe.rb +53 -0
- data/lib/llm/evals/render/reports/html.rb +266 -0
- data/lib/llm/evals/render/reports/json.rb +172 -0
- data/lib/llm/evals/render/reports/pipeline.rb +153 -0
- data/lib/llm/evals/render/reports/terminal.rb +46 -0
- data/lib/llm/evals/reply.rb +18 -0
- data/lib/llm/evals/run_result.rb +14 -0
- data/lib/llm/evals/runtime.rb +74 -0
- data/lib/llm/evals/schema_validator.rb +84 -0
- data/lib/llm/evals/score.rb +26 -0
- data/lib/llm/evals/scorer.rb +45 -0
- data/lib/llm/evals/templates/html_report.css +206 -0
- data/lib/llm/evals/templates/html_report.html.erb +184 -0
- data/lib/llm/evals/templates/html_report.js +48 -0
- data/lib/llm/evals/testing/echo_adapter.rb +29 -0
- data/lib/llm/evals/testing/fake_model.rb +18 -0
- data/lib/llm/evals/testing/fake_provider.rb +67 -0
- data/lib/llm/evals/tracker.rb +80 -0
- data/lib/llm/evals/trackers/braintrust.rb +91 -0
- data/lib/llm/evals/trackers/local.rb +78 -0
- data/lib/llm/evals/trackers/logfire.rb +143 -0
- data/lib/llm/evals/trackers/registry.rb +82 -0
- data/lib/llm/evals/usage.rb +33 -0
- data/lib/llm/evals/verify.rb +49 -0
- data/lib/llm/evals/version.rb +7 -0
- data/lib/llm/evals/warnings.rb +98 -0
- data/lib/llm/evals/weighted_score.rb +116 -0
- data/lib/llm/evals.rb +83 -0
- 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
|