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
data/lib/llm/evals/runtime.rb
CHANGED
|
@@ -1,74 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "dotenv"
|
|
4
|
-
|
|
5
3
|
module LLM
|
|
6
4
|
module Evals
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
# the providers' clients.
|
|
10
|
-
class Runtime
|
|
11
|
-
attr_reader :env_files, :hooks, :app_name
|
|
12
|
-
|
|
13
|
-
# @param env_files [Array<String>] absolute paths; those that exist load
|
|
14
|
-
# @param hooks [Array<Proc>] the suite's `before_run` blocks
|
|
15
|
-
# @param app_name [String] what a provider tells its API the caller is
|
|
16
|
-
def initialize(env_files: [], hooks: [], app_name: "llm-evals")
|
|
17
|
-
@env_files = env_files
|
|
18
|
-
@hooks = hooks
|
|
19
|
-
@app_name = app_name
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# Loads the env files that exist, without overriding what is already
|
|
23
|
-
# set, so an exported variable beats the file.
|
|
24
|
-
#
|
|
25
|
-
# @return [void]
|
|
26
|
-
def load_env
|
|
27
|
-
files = env_files.select { File.exist?(it) }
|
|
28
|
-
Dotenv.load(*files) unless files.empty?
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Fails before anything is called when a key the run needs is neither
|
|
32
|
-
# exported nor in an env file.
|
|
33
|
-
#
|
|
34
|
-
# @param names [Array<String>]
|
|
35
|
-
# @return [void]
|
|
36
|
-
# @raise [Error] naming every missing variable
|
|
37
|
-
def require_env!(names)
|
|
38
|
-
missing = names.uniq.select { ENV[it].to_s.strip.empty? }
|
|
39
|
-
return if missing.empty?
|
|
40
|
-
|
|
41
|
-
found = env_files.select { File.exist?(it) }
|
|
42
|
-
where = if found.empty?
|
|
43
|
-
env_files.empty? ? "no env files are configured" : "there is no #{env_files.join(" or ")}"
|
|
44
|
-
else
|
|
45
|
-
"checked the environment, then #{found.join(", ")}"
|
|
46
|
-
end
|
|
47
|
-
raise Error,
|
|
48
|
-
"missing #{missing.join(", ")}: export them or add them to an env file (#{where}); " \
|
|
49
|
-
"an exported blank value wins over the file"
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# @param providers [Array<Providers::Base>] the providers the run's models use
|
|
53
|
-
# @param tracker [Class<Tracker>, nil] the remote tracker chosen, whose gems and key are checked too
|
|
54
|
-
# @return [void]
|
|
55
|
-
# @raise [Error] when a needed variable or gem is missing
|
|
56
|
-
def setup!(providers: [], tracker: nil)
|
|
57
|
-
load_env
|
|
58
|
-
require_env!(providers.flat_map(&:env_keys))
|
|
59
|
-
tracker&.requirements!(ENV)
|
|
60
|
-
hooks.each(&:call) # after load_env: a hook usually re-reads ENV
|
|
61
|
-
providers.each { it.setup!(app_name: app_name) }
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
# Opens a URL in the desktop browser.
|
|
65
|
-
#
|
|
66
|
-
# @param url [String]
|
|
67
|
-
# @return [Boolean] whether the opener ran
|
|
68
|
-
def browse(url)
|
|
69
|
-
opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
|
|
70
|
-
system(opener, url, out: File::NULL, err: File::NULL) || false
|
|
71
|
-
end
|
|
5
|
+
# How a suite is loaded and set up: its settings, its eval helper, its environment.
|
|
6
|
+
module Runtime
|
|
72
7
|
end
|
|
73
8
|
end
|
|
74
9
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Scoring
|
|
6
|
+
# One number a {Scorer} reports per case. `aggregate` is how the run
|
|
7
|
+
# combines them (only :mean today), skipping nil, so a metric a case does
|
|
8
|
+
# not apply to never drags its mean down with a free 0 or up with a free 1.
|
|
9
|
+
# The `primary` metric ranks the leaderboard and picks the best value.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] name
|
|
12
|
+
# @return [String] the column's key
|
|
13
|
+
# @!attribute [r] description
|
|
14
|
+
# @return [String] the glossary's line
|
|
15
|
+
# @!attribute [r] aggregate
|
|
16
|
+
# @return [Symbol] one of {AGGREGATES}
|
|
17
|
+
# @!attribute [r] primary
|
|
18
|
+
# @return [Boolean] whether it ranks the leaderboard
|
|
19
|
+
class Metric < Data.define(:name, :description, :aggregate, :primary)
|
|
20
|
+
# The ways {#combine} can be asked to combine values.
|
|
21
|
+
AGGREGATES = %i[mean].freeze
|
|
22
|
+
|
|
23
|
+
# Turns the name into a String, so a metric can be declared with a Symbol.
|
|
24
|
+
#
|
|
25
|
+
# @param name [String, Symbol] the column's key
|
|
26
|
+
# @param description [String] the glossary's line
|
|
27
|
+
# @param aggregate [Symbol] one of {AGGREGATES}
|
|
28
|
+
# @param primary [Boolean] whether it ranks the leaderboard
|
|
29
|
+
# @raise [ArgumentError] on an aggregate outside {AGGREGATES}
|
|
30
|
+
def initialize(name:, description: "", aggregate: :mean, primary: false)
|
|
31
|
+
raise ArgumentError, "aggregate must be one of #{AGGREGATES.join(", ")}" unless AGGREGATES.include?(aggregate)
|
|
32
|
+
|
|
33
|
+
super(name: name.to_s, description: description, aggregate: aggregate, primary: primary)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Averages what the cases reported, skipping nil.
|
|
37
|
+
#
|
|
38
|
+
# @param values [Array<Float, nil>] one per case
|
|
39
|
+
# @return [Float, nil] nil when no case reported the metric
|
|
40
|
+
def combine(values)
|
|
41
|
+
present = values.compact
|
|
42
|
+
present.sum.fdiv(present.size) unless present.empty?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Scoring
|
|
6
|
+
# Checks a Hash of answers against a field map, for scorers and verifiers
|
|
7
|
+
# whose expected values are typed and closed. Nothing here is specific to
|
|
8
|
+
# what the fields mean.
|
|
9
|
+
#
|
|
10
|
+
# The map is `{ "name" => { type:, enum:, nullable: } }`; `type` is one of
|
|
11
|
+
# {TYPES}, `enum` lists the allowed values for enum and multi_enum, and a
|
|
12
|
+
# nullable field accepts nil (the text never answered it).
|
|
13
|
+
class SchemaValidator
|
|
14
|
+
# The types a field may declare.
|
|
15
|
+
TYPES = %i[boolean integer number string enum multi_enum].freeze
|
|
16
|
+
|
|
17
|
+
# The map as given, with String names, and each spec normalized to type, enum and nullable.
|
|
18
|
+
#
|
|
19
|
+
# @return [Hash{String => Hash{Symbol => Object}}]
|
|
20
|
+
attr_reader :fields
|
|
21
|
+
|
|
22
|
+
# @param fields [Hash{String, Symbol => Hash}]
|
|
23
|
+
# @raise [ArgumentError] on a type outside {TYPES}, since a typo there would pass everything
|
|
24
|
+
def initialize(fields)
|
|
25
|
+
@fields = fields.to_h { |name, spec| [name.to_s, normalize(name, spec)] }.freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Array<String>] the field names
|
|
29
|
+
def names = fields.keys
|
|
30
|
+
|
|
31
|
+
# @param values [Hash] String or Symbol keys
|
|
32
|
+
# @return [Array<String>] every problem found; empty when the values fit the map
|
|
33
|
+
def problems(values)
|
|
34
|
+
return ["#{values.inspect} is not a Hash"] unless values.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
actual = values.transform_keys(&:to_s)
|
|
37
|
+
missing = names - actual.keys
|
|
38
|
+
extra = actual.keys - names
|
|
39
|
+
problems = []
|
|
40
|
+
problems << "missing #{missing.join(", ")}" if missing.any?
|
|
41
|
+
problems << "unknown fields #{extra.join(", ")}" if extra.any?
|
|
42
|
+
fields.each do |name, spec|
|
|
43
|
+
next unless actual.key?(name)
|
|
44
|
+
|
|
45
|
+
problem = value_problem(spec, actual[name])
|
|
46
|
+
problems << "#{name}: #{problem}" if problem
|
|
47
|
+
end
|
|
48
|
+
problems
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param values [Hash] String or Symbol keys
|
|
52
|
+
# @return [Boolean] whether {#problems} finds none
|
|
53
|
+
def valid?(values) = problems(values).empty?
|
|
54
|
+
|
|
55
|
+
# @param spec [Hash] one field's normalized spec
|
|
56
|
+
# @param value [Object]
|
|
57
|
+
# @return [String, nil] what is wrong with the value, or nil
|
|
58
|
+
def value_problem(spec, value)
|
|
59
|
+
return (spec[:nullable] ? nil : "is null") if value.nil?
|
|
60
|
+
|
|
61
|
+
case spec[:type]
|
|
62
|
+
when :boolean then "#{value.inspect} is not a boolean" unless [true, false].include?(value)
|
|
63
|
+
when :integer then "#{value.inspect} is not an integer" unless value.is_a?(Integer)
|
|
64
|
+
when :number then "#{value.inspect} is not a number" unless value.is_a?(Numeric)
|
|
65
|
+
when :string then "#{value.inspect} is not a string" unless value.is_a?(String)
|
|
66
|
+
when :enum then "#{value.inspect} is not one of #{spec[:enum].join("|")}" unless spec[:enum].include?(value)
|
|
67
|
+
when :multi_enum then multi_enum_problem(spec, value)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Checks that a multi_enum is a list holding only allowed values.
|
|
74
|
+
#
|
|
75
|
+
# @param spec [Hash] one field's normalized spec
|
|
76
|
+
# @param value [Object] the field's non-nil value
|
|
77
|
+
# @return [String, nil] what is wrong with the value, or nil
|
|
78
|
+
def multi_enum_problem(spec, value)
|
|
79
|
+
return "#{value.inspect} is not an array" unless value.is_a?(Array)
|
|
80
|
+
|
|
81
|
+
stray = value - spec[:enum]
|
|
82
|
+
"#{stray.join(", ")} not in #{spec[:enum].join("|")}" if stray.any?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Accepts `values:` as a synonym for `enum:`, and makes allowed values Strings.
|
|
86
|
+
#
|
|
87
|
+
# @param name [String, Symbol] the field, for the error
|
|
88
|
+
# @param spec [Hash] the field's spec, with String or Symbol keys
|
|
89
|
+
# @return [Hash{Symbol => Object}] with type, enum and nullable
|
|
90
|
+
# @raise [ArgumentError] on a type outside {TYPES}
|
|
91
|
+
# @raise [KeyError] when the spec has no type
|
|
92
|
+
def normalize(name, spec)
|
|
93
|
+
spec = spec.transform_keys(&:to_sym)
|
|
94
|
+
type = spec.fetch(:type).to_sym
|
|
95
|
+
raise ArgumentError, "#{name}: unknown type #{type.inspect}, expected one of #{TYPES.join(", ")}" unless TYPES.include?(type)
|
|
96
|
+
|
|
97
|
+
{ type: type, enum: Array(spec[:enum] || spec[:values]).map(&:to_s), nullable: spec.fetch(:nullable, false) }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Scoring
|
|
6
|
+
# What a suite implements to score one reply. Pure and deterministic: it
|
|
7
|
+
# reads the case, the reply and the adapter's context, and calls nothing.
|
|
8
|
+
#
|
|
9
|
+
# @abstract Subclass and override {#metrics} and {#call}.
|
|
10
|
+
class Scorer
|
|
11
|
+
# The metrics every {Results::Score} this scorer returns carries. The CLI builds
|
|
12
|
+
# its columns, its glossary and its Braintrust scorers from this list,
|
|
13
|
+
# so a metric missing here is a metric nobody sees.
|
|
14
|
+
#
|
|
15
|
+
# @return [Array<Metric>]
|
|
16
|
+
# @raise [NotImplementedError] until a subclass overrides it
|
|
17
|
+
def metrics
|
|
18
|
+
raise NotImplementedError, "#{self.class}#metrics must return an Array of LLM::Evals::Scoring::Metric"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Metric] the one flagged primary, else the first
|
|
22
|
+
def primary = metrics.find(&:primary) || metrics.first
|
|
23
|
+
|
|
24
|
+
# @param kase [Datasets::Case]
|
|
25
|
+
# @param reply [Results::Reply] never one with `error` set; the runner scores those 0 itself
|
|
26
|
+
# @param context [Hash] what {Actions::Adapter#context} returned for the case
|
|
27
|
+
# @return [Results::Score]
|
|
28
|
+
# @raise [NotImplementedError] until a subclass overrides it
|
|
29
|
+
def call(kase, reply, context:)
|
|
30
|
+
raise NotImplementedError, "#{self.class}#call(kase, reply, context:) must return an LLM::Evals::Results::Score"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Offline checks of one case against what the scorer will demand of it,
|
|
34
|
+
# so a typo in expected.json is caught before it is billed as a model's mistake.
|
|
35
|
+
#
|
|
36
|
+
# @param kase [Datasets::Case]
|
|
37
|
+
# @param context [Hash] what {Actions::Adapter#context} returned for the case
|
|
38
|
+
# @return [Array<String>] problems; empty when the case is sound
|
|
39
|
+
def verify(kase, context:) = [] # rubocop:disable Lint/UnusedMethodArgument -- the signature is the contract
|
|
40
|
+
|
|
41
|
+
# One short cell describing the case in `llm-evals cases`, e.g. "5/8 answered".
|
|
42
|
+
#
|
|
43
|
+
# @param _kase [Datasets::Case] the case to describe
|
|
44
|
+
# @return [String, nil] nil for the generic fallback
|
|
45
|
+
def summary(_kase) = nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Scoring
|
|
6
|
+
# One number from the leaderboard's columns, weighted as the lineup file
|
|
7
|
+
# says, for a suite to state what matters most to it. A positive weight
|
|
8
|
+
# means higher is better; a negative one, lower is better:
|
|
9
|
+
#
|
|
10
|
+
# weighted_score:
|
|
11
|
+
# weights:
|
|
12
|
+
# score: 0.4
|
|
13
|
+
# hallucinated_rate: -0.2
|
|
14
|
+
# cost: -0.2
|
|
15
|
+
#
|
|
16
|
+
# weighted_score = Σ(|weight| × goodness) / Σ|weight|
|
|
17
|
+
#
|
|
18
|
+
# Goodness runs from 0 to 1. Metrics are used as they are, or as 1 - value
|
|
19
|
+
# under a negative weight. {MAGNITUDES}
|
|
20
|
+
# are placed on a log scale between the lowest and highest in the group, so
|
|
21
|
+
# that each doubling costs the same: on a linear scale, one model a hundred
|
|
22
|
+
# times the price would squash all the others together. Where the whole
|
|
23
|
+
# group is equal, each gets 1.
|
|
24
|
+
#
|
|
25
|
+
# Errors come off the total, when weighted: 20 points for one, 25 for two, 30
|
|
26
|
+
# for three, and so on, down to 0. A failed case has no scores, so no other
|
|
27
|
+
# column notices it, and a single failure must show. A clean contender gets
|
|
28
|
+
# the whole of the weight on errors.
|
|
29
|
+
#
|
|
30
|
+
# Magnitudes are relative, so a weighted score compares within one group
|
|
31
|
+
# only: another model in the lineup moves everyone's.
|
|
32
|
+
class WeightedScore
|
|
33
|
+
# Columns compared on a log scale.
|
|
34
|
+
MAGNITUDES = %w[time cost latency p95].freeze
|
|
35
|
+
|
|
36
|
+
# Columns that add up over a contender's datasets; the rest average, which
|
|
37
|
+
# for p95 is an approximation good enough to rank by.
|
|
38
|
+
SUMMED = %w[errors time cost].freeze
|
|
39
|
+
|
|
40
|
+
# The gem's columns that can be weighted, beside the metrics. `temp` is a
|
|
41
|
+
# setting, not a result, and tokens are already in the cost.
|
|
42
|
+
COLUMNS = ["score", "errors", *MAGNITUDES].freeze
|
|
43
|
+
|
|
44
|
+
# What errors take off the total: {ERROR_BASE} + {ERROR_STEP} per error.
|
|
45
|
+
ERROR_BASE = 0.15
|
|
46
|
+
# The second part of the errors' penalty; see {ERROR_BASE}.
|
|
47
|
+
ERROR_STEP = 0.05
|
|
48
|
+
|
|
49
|
+
# The weights as the lineup gives them, without those that are zero.
|
|
50
|
+
#
|
|
51
|
+
# @return [Hash{String => Numeric}] by column
|
|
52
|
+
attr_reader :weights
|
|
53
|
+
|
|
54
|
+
# @param weights [Hash{String => Numeric}]
|
|
55
|
+
# @param metrics [Array<String>] the scorer's metric names
|
|
56
|
+
# @raise [Error] on a weight that is not a number, an unknown column, or no weight at all
|
|
57
|
+
def initialize(weights, metrics:)
|
|
58
|
+
known = (metrics + COLUMNS).uniq
|
|
59
|
+
unknown = weights.keys - known
|
|
60
|
+
raise Error, "weighted_score: unknown columns #{unknown.join(", ")}; known: #{known.join(", ")}" if unknown.any?
|
|
61
|
+
|
|
62
|
+
invalid = weights.reject { |_name, weight| weight.is_a?(Numeric) }.keys
|
|
63
|
+
raise Error, "weighted_score: weights must be numbers: #{invalid.join(", ")}" if invalid.any?
|
|
64
|
+
|
|
65
|
+
raise Error, "weighted_score: errors must have a negative weight, since fewer is better" if weights.fetch("errors", -1).positive?
|
|
66
|
+
|
|
67
|
+
@weights = weights.reject { |_name, weight| weight.zero? }
|
|
68
|
+
raise Error, "weighted_score: every weight is zero" if @weights.empty?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# A column no row has does not count. A row missing a column the others
|
|
72
|
+
# have gets nil: it was not measured on everything they were.
|
|
73
|
+
#
|
|
74
|
+
# @param rows [Array<Hash{String => Numeric, nil}>] one per contender, compared with each other
|
|
75
|
+
# @return [Array<Float, nil>] in the order given
|
|
76
|
+
def call(rows)
|
|
77
|
+
present = weights.select { |name, _weight| rows.any? { it[name] } }
|
|
78
|
+
total = present.values.sum(&:abs)
|
|
79
|
+
rows.map do |row|
|
|
80
|
+
next if present.empty? || present.keys.any? { row[it].nil? }
|
|
81
|
+
|
|
82
|
+
score = present.sum { |name, weight| weight.abs * goodness(name, row[name], weight, rows) } / total
|
|
83
|
+
[score - penalty(row["errors"], present), 0.0].max
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# What each weight really counts for, since weights need not add up to 1.
|
|
88
|
+
#
|
|
89
|
+
# @return [Hash{String => Float}] by column, each from 0 to 1, largest first
|
|
90
|
+
def shares
|
|
91
|
+
total = weights.values.sum(&:abs).to_f
|
|
92
|
+
weights.sort_by { |_name, weight| -weight.abs }.to_h { |name, weight| [name, weight.abs / total] }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [String] the formula, with this lineup's weights, for the glossary
|
|
96
|
+
def description
|
|
97
|
+
"Σ(|weight| × goodness) / Σ|weight|, from 0% to 100%, and higher is better. A negative weight means that column is better low. " \
|
|
98
|
+
"Errors come off the total: 20 points for one, then 5 more for each further error. " \
|
|
99
|
+
"#{MAGNITUDES.join(", ")} are on a log scale within the group, so compare wscore within one table only. " \
|
|
100
|
+
"Weights: #{weights.map { |name, weight| "#{name} #{weight}" }.join(", ")}."
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# What errors take off the total, when errors carry a weight.
|
|
106
|
+
#
|
|
107
|
+
# @param errors [Numeric, nil] the row's error count; only nil when errors are not weighted
|
|
108
|
+
# @param present [Hash{String => Numeric}] the weights of the columns some row has
|
|
109
|
+
# @return [Float] 0.0 for a clean row
|
|
110
|
+
def penalty(errors, present)
|
|
111
|
+
return 0.0 unless present.key?("errors") && errors.positive?
|
|
112
|
+
|
|
113
|
+
ERROR_BASE + (ERROR_STEP * errors)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# How good one value is, from 0 to 1, with its weight's sign applied.
|
|
117
|
+
#
|
|
118
|
+
# @param name [String] the column
|
|
119
|
+
# @param value [Numeric] the row's value
|
|
120
|
+
# @param weight [Numeric] the column's weight, not zero
|
|
121
|
+
# @param rows [Array<Hash{String => Numeric, nil}>] the group it is compared with
|
|
122
|
+
# @return [Float] 1.0 for errors, which {#penalty} accounts for, and where the group is equal
|
|
123
|
+
def goodness(name, value, weight, rows)
|
|
124
|
+
return 1.0 if name == "errors"
|
|
125
|
+
|
|
126
|
+
position = MAGNITUDES.include?(name) ? position(value, rows.filter_map { it[name] }) : value.to_f
|
|
127
|
+
return 1.0 if position.nil?
|
|
128
|
+
|
|
129
|
+
weight.negative? ? 1.0 - position : position
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# A zero has no logarithm, so it stands at half the lowest above zero.
|
|
133
|
+
#
|
|
134
|
+
# @param value [Numeric] the value to place
|
|
135
|
+
# @param values [Array<Numeric>] the group's values, the value among them
|
|
136
|
+
# @return [Float, nil] from 0 at the lowest to 1 at the highest; nil when all are equal
|
|
137
|
+
def position(value, values)
|
|
138
|
+
highest = values.max
|
|
139
|
+
return nil if values.min == highest
|
|
140
|
+
|
|
141
|
+
lowest = values.select(&:positive?).min
|
|
142
|
+
lowest /= 2.0 if values.any?(&:zero?)
|
|
143
|
+
(Math.log([value, lowest].max) - Math.log(lowest)) / (Math.log(highest) - Math.log(lowest))
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Stats
|
|
6
|
+
# How many calls a run made and how fast they came back, per provider, per
|
|
7
|
+
# vendor behind it, and per model at every setting. Latency counts only
|
|
8
|
+
# calls that worked, since a refusal returns quickly and would flatter the
|
|
9
|
+
# slowest model. Requests per second are over the whole run, so the rows
|
|
10
|
+
# compare with each other.
|
|
11
|
+
class CallStats
|
|
12
|
+
# The share {Stats.percentile} is asked for, shared with {Actions::Experiment::Summary#latency_p95}.
|
|
13
|
+
P95 = 0.95
|
|
14
|
+
|
|
15
|
+
# One line of the report: a provider, a vendor behind it, or one model at one setting.
|
|
16
|
+
#
|
|
17
|
+
# @!attribute [r] scope
|
|
18
|
+
# @return [Symbol] :provider, :vendor or :model
|
|
19
|
+
# @!attribute [r] name
|
|
20
|
+
# @return [String] the provider's name, or "provider/vendor" or "provider/model"
|
|
21
|
+
# @!attribute [r] requests
|
|
22
|
+
# @return [Integer] every call made, including failures
|
|
23
|
+
# @!attribute [r] errors
|
|
24
|
+
# @return [Integer] calls that raised or that the model failed
|
|
25
|
+
# @!attribute [r] per_second
|
|
26
|
+
# @return [Float, nil] over the whole run; nil when its length is unknown or zero
|
|
27
|
+
# @!attribute [r] latency
|
|
28
|
+
# @return [Float, nil] mean seconds of the calls that worked; nil with none
|
|
29
|
+
# @!attribute [r] p95
|
|
30
|
+
# @return [Float, nil] 95th percentile seconds of the calls that worked; nil with none
|
|
31
|
+
Row = Data.define(:scope, :name, :requests, :errors, :per_second, :latency, :p95)
|
|
32
|
+
|
|
33
|
+
# Reads the run's length out of its recorded options, however they were stored.
|
|
34
|
+
#
|
|
35
|
+
# @param summaries [Array<Actions::Experiment::Summary>]
|
|
36
|
+
# @param run_options [Hash] as a run recorded them, with symbol keys or, read back from JSON, string keys
|
|
37
|
+
# @return [CallStats]
|
|
38
|
+
def self.for_run(summaries, run_options)
|
|
39
|
+
new(summaries, seconds: run_options.dig(:effective, :seconds) || run_options.dig("effective", "seconds"))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Computes nothing until {#rows}.
|
|
43
|
+
#
|
|
44
|
+
# @param summaries [Array<Actions::Experiment::Summary>]
|
|
45
|
+
# @param seconds [Numeric, nil] how long the run took; nil leaves requests per second empty
|
|
46
|
+
def initialize(summaries, seconds:)
|
|
47
|
+
@summaries = summaries
|
|
48
|
+
@seconds = seconds
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A direct provider has no vendor rows, since it serves only its own models.
|
|
52
|
+
#
|
|
53
|
+
# @return [Array<Row>] each provider, then its vendors, each followed by its models
|
|
54
|
+
def rows
|
|
55
|
+
@summaries.group_by { it.model.provider }.flat_map do |provider, by_provider|
|
|
56
|
+
[row(:provider, provider.name, by_provider)] + by_provider.group_by { vendor(it) }.flat_map do |name, by_vendor|
|
|
57
|
+
models = by_vendor.group_by { it.model.to_s }.map { |model, group| row(:model, "#{provider.name}/#{model}", group) }
|
|
58
|
+
provider.direct? ? models : [row(:vendor, "#{provider.name}/#{name}", by_vendor), *models]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# The vendor is the id's first segment, "anthropic" in "anthropic/claude-haiku-5.5".
|
|
66
|
+
#
|
|
67
|
+
# @param summary [Actions::Experiment::Summary]
|
|
68
|
+
# @return [String]
|
|
69
|
+
def vendor(summary) = summary.model.id.split("/").first
|
|
70
|
+
|
|
71
|
+
# Totals one group of summaries into a row.
|
|
72
|
+
#
|
|
73
|
+
# @param scope [Symbol] :provider, :vendor or :model
|
|
74
|
+
# @param name [String] the row's label
|
|
75
|
+
# @param summaries [Array<Actions::Experiment::Summary>] the group
|
|
76
|
+
# @return [Row]
|
|
77
|
+
def row(scope, name, summaries)
|
|
78
|
+
results = summaries.flat_map(&:results)
|
|
79
|
+
worked = summaries.flat_map(&:worked_durations)
|
|
80
|
+
Row.new(scope:,
|
|
81
|
+
name:,
|
|
82
|
+
requests: results.size,
|
|
83
|
+
errors: results.count(&:errored?),
|
|
84
|
+
per_second: @seconds&.positive? ? results.size.fdiv(@seconds) : nil,
|
|
85
|
+
latency: Stats.mean(worked),
|
|
86
|
+
p95: Stats.percentile(worked, P95))
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Stats
|
|
6
|
+
# What one call cost, as the provider counted it. Summed over a run.
|
|
7
|
+
#
|
|
8
|
+
# @!attribute [r] input_tokens
|
|
9
|
+
# @return [Integer]
|
|
10
|
+
# @!attribute [r] output_tokens
|
|
11
|
+
# @return [Integer]
|
|
12
|
+
# @!attribute [r] cost_usd
|
|
13
|
+
# @return [Float] in US dollars
|
|
14
|
+
Usage = Data.define(:input_tokens, :output_tokens, :cost_usd) do
|
|
15
|
+
# The start of a sum, and the usage of a reply that never reached a provider.
|
|
16
|
+
#
|
|
17
|
+
# @return [Usage] zero of everything
|
|
18
|
+
def self.zero = new(input_tokens: 0, output_tokens: 0, cost_usd: 0.0)
|
|
19
|
+
|
|
20
|
+
# Reads a Hash with Symbol or String keys, missing keys as zero.
|
|
21
|
+
#
|
|
22
|
+
# @param hash [Hash, Usage, nil]
|
|
23
|
+
# @return [Usage]
|
|
24
|
+
def self.from(hash)
|
|
25
|
+
return hash if hash.is_a?(Usage)
|
|
26
|
+
return zero if hash.nil?
|
|
27
|
+
|
|
28
|
+
h = hash.transform_keys(&:to_sym)
|
|
29
|
+
new(input_tokens: h.fetch(:input_tokens, 0).to_i,
|
|
30
|
+
output_tokens: h.fetch(:output_tokens, 0).to_i,
|
|
31
|
+
cost_usd: h.fetch(:cost_usd, 0.0).to_f)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Adds field by field, which is how a run's usage is totalled.
|
|
35
|
+
#
|
|
36
|
+
# @param other [Usage]
|
|
37
|
+
# @return [Usage]
|
|
38
|
+
def +(other)
|
|
39
|
+
with(input_tokens: input_tokens + other.input_tokens,
|
|
40
|
+
output_tokens: output_tokens + other.output_tokens,
|
|
41
|
+
cost_usd: cost_usd + other.cost_usd)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
# The arithmetic reports and summaries share.
|
|
6
|
+
module Stats
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Nearest-rank, so the value is one a call really took.
|
|
10
|
+
#
|
|
11
|
+
# @param values [Array<Numeric>]
|
|
12
|
+
# @param share [Float] from 0 to 1
|
|
13
|
+
# @return [Numeric, nil] nil with no values
|
|
14
|
+
# @example
|
|
15
|
+
# Stats.percentile([1, 2, 3, 4], 0.5) # => 2
|
|
16
|
+
def percentile(values, share)
|
|
17
|
+
return if values.empty?
|
|
18
|
+
|
|
19
|
+
values.sort[[(share * values.size).ceil - 1, 0].max]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Plain average, sparing callers a division by zero on an empty list.
|
|
23
|
+
#
|
|
24
|
+
# @param values [Array<Numeric>]
|
|
25
|
+
# @return [Float, nil] nil with no values
|
|
26
|
+
def mean(values) = values.empty? ? nil : values.sum.fdiv(values.size)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -7,21 +7,26 @@ module LLM
|
|
|
7
7
|
# prompt, parses the reply as a JSON object, and reports a reply that
|
|
8
8
|
# is not one as the model's failure. The toy suite runs on it, and a
|
|
9
9
|
# new suite can start from it.
|
|
10
|
-
class EchoAdapter < Adapter
|
|
10
|
+
class EchoAdapter < Actions::Adapter
|
|
11
|
+
# The system prompt sent with every case.
|
|
11
12
|
SYSTEM = "Answer with a single JSON object and nothing else."
|
|
13
|
+
# Matches the Markdown code fences some models wrap JSON in, for stripping.
|
|
12
14
|
FENCE = /\A```(?:json)?\s*|\s*```\z/
|
|
13
15
|
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
16
|
+
# Overrides {Actions::Adapter#call}. Failures to parse become the reply's
|
|
17
|
+
# error; transport errors raise, as they do from {Models::Model#complete}.
|
|
18
|
+
#
|
|
19
|
+
# @param kase [Datasets::Case] the case whose input is the prompt
|
|
20
|
+
# @param model [Models::Model] the model to call
|
|
21
|
+
# @return [Results::Reply]
|
|
17
22
|
def call(kase, model:)
|
|
18
|
-
|
|
19
|
-
output = JSON.parse(
|
|
20
|
-
return Reply.new(error: "reply is not a JSON object", raw:
|
|
23
|
+
provider_response = model.complete(system: SYSTEM, user: kase.input, json: true)
|
|
24
|
+
output = JSON.parse(provider_response.text.strip.gsub(FENCE, ""))
|
|
25
|
+
return Results::Reply.new(error: "reply is not a JSON object", raw: provider_response.text, usage: provider_response.usage) unless output.is_a?(Hash)
|
|
21
26
|
|
|
22
|
-
Reply.new(output: output, raw:
|
|
27
|
+
Results::Reply.new(output: output, raw: provider_response.text, usage: provider_response.usage)
|
|
23
28
|
rescue JSON::ParserError
|
|
24
|
-
Reply.new(error: "reply is not JSON", raw:
|
|
29
|
+
Results::Reply.new(error: "reply is not JSON", raw: provider_response.text, usage: provider_response.usage)
|
|
25
30
|
end
|
|
26
31
|
end
|
|
27
32
|
end
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
module LLM
|
|
4
4
|
module Evals
|
|
5
5
|
module Testing
|
|
6
|
-
# A {Model} on a {FakeProvider} of its own, for specs that run an
|
|
7
|
-
# adapter or an {Experiment} without the CLI.
|
|
8
|
-
class FakeModel < Model
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# @param
|
|
6
|
+
# A {Models::Model} on a {FakeProvider} of its own, for specs that run an
|
|
7
|
+
# adapter or an {Actions::Experiment} without the CLI.
|
|
8
|
+
class FakeModel < Models::Model
|
|
9
|
+
# Builds a fresh {FakeProvider}, so its recorded calls belong to this model.
|
|
10
|
+
#
|
|
11
|
+
# @param id [String] the model id
|
|
12
|
+
# @param settings [Hash{Symbol => Object}] effort, temperature, max_tokens
|
|
13
|
+
# @param provider_options [Hash{Symbol => Object}] passed to {FakeProvider#initialize}
|
|
12
14
|
def initialize(id: "fake/echo", settings: { effort: nil, temperature: 0.2, max_tokens: 4096 }, **provider_options)
|
|
13
15
|
super(id: id, provider: FakeProvider.new(**provider_options), settings: settings)
|
|
14
16
|
end
|