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,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Trackers
|
|
6
|
+
# Pydantic Logfire, which has no Ruby gem of its own: it is an OTLP
|
|
7
|
+
# backend for the standard OpenTelemetry Ruby SDK (pydantic.dev/logfire/ruby,
|
|
8
|
+
# read 2026-10-15). Traces only; Logfire's metrics and logs SDKs are
|
|
9
|
+
# pre-1.0. One span per experiment, one child span per case.
|
|
10
|
+
#
|
|
11
|
+
# The write token comes from `LOGFIRE_TOKEN`, our name for what their
|
|
12
|
+
# page passes as a raw `Authorization` header. When the standard
|
|
13
|
+
# `OTEL_EXPORTER_OTLP_ENDPOINT` or `OTEL_EXPORTER_OTLP_HEADERS` is
|
|
14
|
+
# already set, the SDK's own reading of it wins and {#describe} says so.
|
|
15
|
+
# The raw reply is not sent unless the config file opts in with
|
|
16
|
+
# `logfire: { include_replies: true }`.
|
|
17
|
+
class Logfire < Tracker
|
|
18
|
+
# Logfire's US region, per their docs on 2026-10-15; other regions
|
|
19
|
+
# exist, so LOGFIRE_BASE_URL overrides it.
|
|
20
|
+
DEFAULT_BASE_URL = "https://logfire-us.pydantic.dev"
|
|
21
|
+
TOKEN = "LOGFIRE_TOKEN"
|
|
22
|
+
BASE_URL = "LOGFIRE_BASE_URL"
|
|
23
|
+
OTEL_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"
|
|
24
|
+
OTEL_HEADERS = "OTEL_EXPORTER_OTLP_HEADERS"
|
|
25
|
+
|
|
26
|
+
# Bytes of the diagnostics JSON kept on a case span; the rest is cut,
|
|
27
|
+
# since an attribute is not the place for a whole transcript.
|
|
28
|
+
DIAGNOSTICS_LIMIT = 4096
|
|
29
|
+
|
|
30
|
+
def self.env_key = TOKEN
|
|
31
|
+
def self.gems = %w[opentelemetry-sdk opentelemetry-exporter-otlp]
|
|
32
|
+
|
|
33
|
+
attr_reader :endpoint, :source
|
|
34
|
+
|
|
35
|
+
# @param exporter [#export, nil] an exporter to use instead of OTLP, for tests
|
|
36
|
+
# @param env [Hash]
|
|
37
|
+
# @param context [Hash] see {Tracker#initialize}
|
|
38
|
+
def initialize(exporter: nil, env: ENV, **context)
|
|
39
|
+
super(**context)
|
|
40
|
+
@env = env
|
|
41
|
+
@exporter = exporter
|
|
42
|
+
@endpoint, @headers, @source = settings(env)
|
|
43
|
+
@cases = []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def describe = "logfire: endpoint #{endpoint || "(SDK default)"}, credentials from #{source}"
|
|
47
|
+
|
|
48
|
+
# @return [Boolean] whether raw replies ride along on the case spans
|
|
49
|
+
def include_replies? = options["include_replies"] == true || options[:include_replies] == true
|
|
50
|
+
|
|
51
|
+
def start(experiment)
|
|
52
|
+
@provider = provider
|
|
53
|
+
@tracer = @provider.tracer("llm-evals", LLM::Evals::VERSION)
|
|
54
|
+
@span = @tracer.start_span("experiment #{experiment.name}", attributes: experiment_attributes(experiment))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def record(result)
|
|
58
|
+
now = Time.now
|
|
59
|
+
span = @tracer.start_span("case #{result.case.key}",
|
|
60
|
+
with_parent: OpenTelemetry::Trace.context_with_span(@span),
|
|
61
|
+
start_timestamp: now - result.duration,
|
|
62
|
+
attributes: case_attributes(result))
|
|
63
|
+
if result.failed?
|
|
64
|
+
span.record_exception(Error.new(result.error))
|
|
65
|
+
span.status = OpenTelemetry::Trace::Status.error(result.error)
|
|
66
|
+
end
|
|
67
|
+
span.finish(end_timestamp: now)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Closes the experiment span with the summary and flushes. No URL:
|
|
71
|
+
# Logfire's trace pages are not something the token lets us build.
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash] `url:` nil
|
|
74
|
+
def finish(_experiment, summary)
|
|
75
|
+
summary.means.each { |metric, mean| @span.set_attribute("evals.mean.#{metric}", mean) unless mean.nil? }
|
|
76
|
+
@span.set_attribute("evals.errors", summary.errors.size)
|
|
77
|
+
@span.set_attribute("evals.duration_seconds", summary.duration)
|
|
78
|
+
@span.set_attribute("evals.usage.cost_usd", summary.usage.cost_usd)
|
|
79
|
+
@span.finish
|
|
80
|
+
@provider.force_flush
|
|
81
|
+
@provider.shutdown unless @exporter # an injected exporter is the caller's to read afterwards
|
|
82
|
+
{ url: nil }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Which credentials win: the standard OTEL_* variables when either is
|
|
88
|
+
# set, else our LOGFIRE_* pair. Returns the endpoint and headers to
|
|
89
|
+
# hand the exporter (nil leaves the SDK's own reading in place).
|
|
90
|
+
def settings(env)
|
|
91
|
+
if [OTEL_ENDPOINT, OTEL_HEADERS].any? { !env[it].to_s.empty? }
|
|
92
|
+
[env[OTEL_ENDPOINT], nil, "#{OTEL_ENDPOINT}/#{OTEL_HEADERS} (set in the environment; #{TOKEN} ignored)"]
|
|
93
|
+
else
|
|
94
|
+
base = env[BASE_URL].to_s.empty? ? DEFAULT_BASE_URL : env[BASE_URL]
|
|
95
|
+
["#{base.chomp("/")}/v1/traces", { "Authorization" => env[TOKEN].to_s }, "#{TOKEN}#{" and #{BASE_URL}" unless env[BASE_URL].to_s.empty?}"]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A tracer provider of our own, so the global one, which a consumer
|
|
100
|
+
# may have configured for its application, is left alone.
|
|
101
|
+
def provider
|
|
102
|
+
exporter = @exporter || OpenTelemetry::Exporter::OTLP::Exporter.new(**{ endpoint: endpoint, headers: @headers }.compact)
|
|
103
|
+
processor = if @exporter
|
|
104
|
+
OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exporter)
|
|
105
|
+
else
|
|
106
|
+
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter)
|
|
107
|
+
end
|
|
108
|
+
resource = OpenTelemetry::SDK::Resources::Resource.create("service.name" => context[:suite] || "llm-evals")
|
|
109
|
+
OpenTelemetry::SDK::Trace::TracerProvider.new(resource: resource).tap { it.add_span_processor(processor) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def experiment_attributes(experiment)
|
|
113
|
+
model = experiment.model
|
|
114
|
+
{ "evals.suite" => context[:suite].to_s, "evals.project" => context[:project].to_s, "evals.experiment" => experiment.name,
|
|
115
|
+
"evals.model" => model.id, "evals.provider" => model.provider.name, "evals.dataset" => experiment.dataset,
|
|
116
|
+
"evals.effort" => model.effort.to_s, "evals.temperature" => model.temperature.to_f, "evals.max_tokens" => model.max_tokens.to_i,
|
|
117
|
+
"evals.cases" => experiment.cases.size }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def case_attributes(result)
|
|
121
|
+
kase = result.case
|
|
122
|
+
attributes = { "evals.case" => kase.id, "evals.dataset" => kase.dataset, "evals.variant" => kase.variant,
|
|
123
|
+
"evals.duration_seconds" => result.duration }
|
|
124
|
+
return attributes.merge("evals.error" => result.error) if result.failed?
|
|
125
|
+
|
|
126
|
+
score = result.score
|
|
127
|
+
attributes["evals.score"] = score.value
|
|
128
|
+
score.metrics.each { |metric, value| attributes["evals.metric.#{metric}"] = value unless value.nil? }
|
|
129
|
+
score.outcomes.group_by { |_, v| v.to_s }.each { |outcome, pairs| attributes["evals.outcome.#{outcome}"] = pairs.size }
|
|
130
|
+
attributes["evals.diagnostics"] = truncate(JSON.generate(score.diagnostics))
|
|
131
|
+
attributes["evals.usage.input_tokens"] = result.reply.usage.input_tokens
|
|
132
|
+
attributes["evals.usage.output_tokens"] = result.reply.usage.output_tokens
|
|
133
|
+
attributes["evals.usage.cost_usd"] = result.reply.usage.cost_usd
|
|
134
|
+
attributes["evals.reply_error"] = result.reply.error if result.reply.error?
|
|
135
|
+
attributes["evals.reply"] = truncate(result.reply.raw.to_s) if include_replies?
|
|
136
|
+
attributes
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def truncate(text) = text.bytesize > DIAGNOSTICS_LIMIT ? "#{text.byteslice(0, DIAGNOSTICS_LIMIT)}..." : text
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Trackers
|
|
6
|
+
# The tracker classes a run may choose from, by name, and the choice
|
|
7
|
+
# itself: exactly one remote tracker, or none. A consumer or a plugin
|
|
8
|
+
# gem registers its own with `c.tracker`.
|
|
9
|
+
class Registry
|
|
10
|
+
include Enumerable
|
|
11
|
+
|
|
12
|
+
# Spellings of "no remote tracker" on the command line and in the config.
|
|
13
|
+
NONE = %w[none local off].freeze
|
|
14
|
+
|
|
15
|
+
# @return [Registry] Braintrust and Logfire
|
|
16
|
+
def self.default = new([Braintrust, Logfire])
|
|
17
|
+
|
|
18
|
+
# @param classes [Array<Class<Tracker>>]
|
|
19
|
+
def initialize(classes = [])
|
|
20
|
+
@classes = classes.dup
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param klass [Class<Tracker>] replaces one registered under the same name
|
|
24
|
+
# @return [Class<Tracker>]
|
|
25
|
+
def register(klass)
|
|
26
|
+
@classes.reject! { it.tracker_name == klass.tracker_name }
|
|
27
|
+
@classes << klass
|
|
28
|
+
klass
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def each(&) = @classes.each(&)
|
|
32
|
+
|
|
33
|
+
# @return [Array<String>]
|
|
34
|
+
def names = map(&:tracker_name)
|
|
35
|
+
|
|
36
|
+
# @param name [String, Symbol]
|
|
37
|
+
# @return [Class<Tracker>]
|
|
38
|
+
# @raise [Error] naming the known trackers
|
|
39
|
+
def named(name)
|
|
40
|
+
find { it.tracker_name == name.to_s } || raise(Error, "no tracker named #{name.to_s.inspect}; known: #{names.join(", ")}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The one remote tracker to run, in this precedence: `--tracker`,
|
|
44
|
+
# then the config's `tracker:`, then the environment, where a single
|
|
45
|
+
# tracker's key being set selects it and two or more is an ambiguity
|
|
46
|
+
# to be settled by naming one. `--local` is none.
|
|
47
|
+
#
|
|
48
|
+
# @param flag [String, nil] what `--tracker` was given
|
|
49
|
+
# @param config [String, nil] the config file's `tracker:`
|
|
50
|
+
# @param env [Hash]
|
|
51
|
+
# @param local [Boolean] `--local`
|
|
52
|
+
# @return [Array(Class<Tracker>, String), nil] the class and why it was chosen; nil for none
|
|
53
|
+
# @raise [Error] on an unknown name or an ambiguous environment
|
|
54
|
+
def choose(flag: nil, config: nil, env: ENV, local: false)
|
|
55
|
+
return nil if local
|
|
56
|
+
return choice(flag, "--tracker") if present?(flag)
|
|
57
|
+
return choice(config, "tracker: in the config file") if present?(config)
|
|
58
|
+
|
|
59
|
+
detected = @classes.select { it.detect?(env) }
|
|
60
|
+
case detected.size
|
|
61
|
+
when 0 then nil
|
|
62
|
+
when 1 then [detected.first, "#{detected.first.env_key} is set (pass --tracker or set tracker: to choose explicitly)"]
|
|
63
|
+
else
|
|
64
|
+
raise Error,
|
|
65
|
+
"more than one tracker is configured in the environment (#{detected.map(&:env_key).join(", ")}); " \
|
|
66
|
+
"choose one with --tracker #{detected.map(&:tracker_name).join("|")} or tracker: in the config file"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def present?(value) = !value.nil? && !value.to_s.strip.empty?
|
|
73
|
+
|
|
74
|
+
def choice(name, from)
|
|
75
|
+
return nil if NONE.include?(name.to_s)
|
|
76
|
+
|
|
77
|
+
[named(name), from]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
# What one call cost, as the provider counted it. Summed over a run.
|
|
6
|
+
Usage = Data.define(:input_tokens, :output_tokens, :cost_usd) do
|
|
7
|
+
# @return [Usage] zero of everything
|
|
8
|
+
def self.zero = new(input_tokens: 0, output_tokens: 0, cost_usd: 0.0)
|
|
9
|
+
|
|
10
|
+
# Reads a Hash with Symbol or String keys, missing keys as zero.
|
|
11
|
+
#
|
|
12
|
+
# @param hash [Hash, Usage, nil]
|
|
13
|
+
# @return [Usage]
|
|
14
|
+
def self.from(hash)
|
|
15
|
+
return hash if hash.is_a?(Usage)
|
|
16
|
+
return zero if hash.nil?
|
|
17
|
+
|
|
18
|
+
h = hash.transform_keys(&:to_sym)
|
|
19
|
+
new(input_tokens: h.fetch(:input_tokens, 0).to_i,
|
|
20
|
+
output_tokens: h.fetch(:output_tokens, 0).to_i,
|
|
21
|
+
cost_usd: h.fetch(:cost_usd, 0.0).to_f)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param other [Usage]
|
|
25
|
+
# @return [Usage]
|
|
26
|
+
def +(other)
|
|
27
|
+
with(input_tokens: input_tokens + other.input_tokens,
|
|
28
|
+
output_tokens: output_tokens + other.output_tokens,
|
|
29
|
+
cost_usd: cost_usd + other.cost_usd)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
# Offline consistency checks over every case, so a typo in the suite is
|
|
6
|
+
# caught before it is billed as a model's mistake. The gem checks what
|
|
7
|
+
# every suite has: an input and a parsable expected answer. The adapter
|
|
8
|
+
# checks each dataset's shared files, and the scorer each case against
|
|
9
|
+
# what it will demand of it.
|
|
10
|
+
class Verify
|
|
11
|
+
# @param configuration [Configuration]
|
|
12
|
+
def initialize(configuration = LLM::Evals.configuration)
|
|
13
|
+
@configuration = configuration
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Hash{String => Array<String>}] problems by "dataset/id"; empty when all is sound
|
|
17
|
+
def problems
|
|
18
|
+
discovery = @configuration.discovery
|
|
19
|
+
failures = {}
|
|
20
|
+
discovery.datasets.each do |dataset|
|
|
21
|
+
Array(adapter.verify(dataset)).each { (failures[dataset.to_s] ||= []) << it }
|
|
22
|
+
discovery.cases(dataset: dataset).each do |kase|
|
|
23
|
+
list = case_problems(kase)
|
|
24
|
+
failures[kase.key] = list unless list.empty?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
failures
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param kase [Case]
|
|
31
|
+
# @return [Array<String>]
|
|
32
|
+
def case_problems(kase)
|
|
33
|
+
problems = []
|
|
34
|
+
problems << "input is empty" if kase.input.empty?
|
|
35
|
+
problems << kase.metadata[:expected_error] if kase.metadata[:expected_error]
|
|
36
|
+
return problems unless kase.expected?
|
|
37
|
+
|
|
38
|
+
problems.concat(Array(scorer.verify(kase, context: adapter.context(kase))))
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
problems << "#{e.class}: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def adapter = @configuration.adapter
|
|
46
|
+
def scorer = @configuration.scorer
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module LLM
|
|
6
|
+
module Evals
|
|
7
|
+
# Everything a run would otherwise print as a warning while its progress
|
|
8
|
+
# bars redraw: cases that failed, and what Braintrust, RubyLLM, OpenTelemetry
|
|
9
|
+
# (whose exporters report failures on $stdout) and Ruby itself log. Printed among the bars, each one breaks the redraw; collected
|
|
10
|
+
# here, they are saved to one file, and the run prints only how many.
|
|
11
|
+
#
|
|
12
|
+
# It quacks as an IO, so a Logger, and $stderr, can write to it. Threads
|
|
13
|
+
# share it.
|
|
14
|
+
class Warnings
|
|
15
|
+
def initialize
|
|
16
|
+
@lines = []
|
|
17
|
+
@lock = Mutex.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param text [#to_s]
|
|
21
|
+
# @return [self]
|
|
22
|
+
def record(text)
|
|
23
|
+
text = text.to_s.chomp
|
|
24
|
+
@lock.synchronize { @lines << text } unless text.strip.empty?
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Integer] the bytes "written", as IO#write does
|
|
29
|
+
def write(*texts)
|
|
30
|
+
texts.each { record(it) }
|
|
31
|
+
texts.sum { it.to_s.bytesize }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def puts(*texts) = texts.each { record(it) } && nil
|
|
35
|
+
def print(*texts) = write(*texts) && nil
|
|
36
|
+
def <<(text) = record(text)
|
|
37
|
+
def flush = self
|
|
38
|
+
def sync = true
|
|
39
|
+
def sync=(_value); end
|
|
40
|
+
def close = nil
|
|
41
|
+
def tty? = false
|
|
42
|
+
|
|
43
|
+
# @return [Integer]
|
|
44
|
+
def count = @lock.synchronize { @lines.size }
|
|
45
|
+
|
|
46
|
+
# @return [Array<String>]
|
|
47
|
+
def to_a = @lock.synchronize { @lines.dup }
|
|
48
|
+
|
|
49
|
+
# @return [Boolean] whether nothing was recorded
|
|
50
|
+
def none? = count.zero?
|
|
51
|
+
|
|
52
|
+
# @param path [String]
|
|
53
|
+
# @return [String] the path
|
|
54
|
+
def save(path)
|
|
55
|
+
File.write(path, "#{to_a.join("\n\n")}\n")
|
|
56
|
+
path
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Runs the block with $stderr, Ruby's own warnings, and the loggers of
|
|
60
|
+
# whichever optional gems are loaded writing here, and puts them back after.
|
|
61
|
+
#
|
|
62
|
+
# @return [Object] what the block returns
|
|
63
|
+
def capture
|
|
64
|
+
logger = Logger.new(self, level: Logger::WARN)
|
|
65
|
+
stderr = $stderr
|
|
66
|
+
kernel_warn = Kernel.instance_method(:warn)
|
|
67
|
+
warnings = self
|
|
68
|
+
restore = redirect_loggers(logger)
|
|
69
|
+
$stderr = self
|
|
70
|
+
Kernel.define_method(:warn) { |*messages, **_options| warnings.write(messages.join("\n")) }
|
|
71
|
+
Kernel.send(:private, :warn)
|
|
72
|
+
yield
|
|
73
|
+
ensure
|
|
74
|
+
Kernel.define_method(:warn, kernel_warn) if kernel_warn
|
|
75
|
+
Kernel.send(:private, :warn)
|
|
76
|
+
restore&.call
|
|
77
|
+
$stderr = stderr
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# @return [Proc] puts the loggers back
|
|
83
|
+
def redirect_loggers(logger)
|
|
84
|
+
braintrust = defined?(Braintrust::Log) ? Braintrust::Log.logger : nil
|
|
85
|
+
ruby_llm = defined?(RubyLLM) ? RubyLLM.instance_variable_get(:@logger) : nil
|
|
86
|
+
open_telemetry = defined?(OpenTelemetry) ? OpenTelemetry.logger : nil
|
|
87
|
+
Braintrust::Log.logger = logger if defined?(Braintrust::Log)
|
|
88
|
+
RubyLLM.instance_variable_set(:@logger, logger) if defined?(RubyLLM)
|
|
89
|
+
OpenTelemetry.logger = logger if defined?(OpenTelemetry)
|
|
90
|
+
lambda do
|
|
91
|
+
Braintrust::Log.logger = braintrust if defined?(Braintrust::Log)
|
|
92
|
+
RubyLLM.instance_variable_set(:@logger, ruby_llm) if defined?(RubyLLM)
|
|
93
|
+
OpenTelemetry.logger = open_telemetry if defined?(OpenTelemetry)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
# One number from the leaderboard's columns, weighted as the lineup file
|
|
6
|
+
# says, for a suite to state what matters most to it. A positive weight
|
|
7
|
+
# means higher is better; a negative one, lower is better:
|
|
8
|
+
#
|
|
9
|
+
# weighted_score:
|
|
10
|
+
# weights:
|
|
11
|
+
# score: 0.4
|
|
12
|
+
# hallucinated_rate: -0.2
|
|
13
|
+
# cost: -0.2
|
|
14
|
+
#
|
|
15
|
+
# weighted_score = Σ(|weight| × goodness) / Σ|weight|
|
|
16
|
+
#
|
|
17
|
+
# Goodness runs from 0 to 1. Metrics are used as they are, or as 1 - value
|
|
18
|
+
# under a negative weight. {MAGNITUDES}
|
|
19
|
+
# are placed on a log scale between the lowest and highest in the group, so
|
|
20
|
+
# that each doubling costs the same: on a linear scale, one model a hundred
|
|
21
|
+
# times the price would squash all the others together. Where the whole
|
|
22
|
+
# group is equal, each gets 1.
|
|
23
|
+
#
|
|
24
|
+
# Errors come off the total, when weighted: 20 points for one, 25 for two, 30
|
|
25
|
+
# for three, and so on, down to 0. A failed case has no scores, so no other
|
|
26
|
+
# column notices it, and a single failure must show. A clean contender gets
|
|
27
|
+
# the whole of the weight on errors.
|
|
28
|
+
#
|
|
29
|
+
# Magnitudes are relative, so a weighted score compares within one group
|
|
30
|
+
# only: another model in the lineup moves everyone's.
|
|
31
|
+
class WeightedScore
|
|
32
|
+
# Columns compared on a log scale.
|
|
33
|
+
MAGNITUDES = %w[time cost].freeze
|
|
34
|
+
|
|
35
|
+
# The gem's columns that can be weighted, beside the metrics. `temp` is a
|
|
36
|
+
# setting, not a result, and tokens are already in the cost.
|
|
37
|
+
COLUMNS = ["score", "errors", *MAGNITUDES].freeze
|
|
38
|
+
|
|
39
|
+
# What errors take off the total: {ERROR_BASE} + {ERROR_STEP} per error.
|
|
40
|
+
ERROR_BASE = 0.15
|
|
41
|
+
ERROR_STEP = 0.05
|
|
42
|
+
|
|
43
|
+
attr_reader :weights
|
|
44
|
+
|
|
45
|
+
# @param weights [Hash{String => Numeric}]
|
|
46
|
+
# @param metrics [Array<String>] the scorer's metric names
|
|
47
|
+
# @raise [Error] on a weight that is not a number, an unknown column, or no weight at all
|
|
48
|
+
def initialize(weights, metrics:)
|
|
49
|
+
known = (metrics + COLUMNS).uniq
|
|
50
|
+
unknown = weights.keys - known
|
|
51
|
+
raise Error, "weighted_score: unknown columns #{unknown.join(", ")}; known: #{known.join(", ")}" if unknown.any?
|
|
52
|
+
|
|
53
|
+
invalid = weights.reject { |_name, weight| weight.is_a?(Numeric) }.keys
|
|
54
|
+
raise Error, "weighted_score: weights must be numbers: #{invalid.join(", ")}" if invalid.any?
|
|
55
|
+
|
|
56
|
+
raise Error, "weighted_score: errors must have a negative weight, since fewer is better" if weights.fetch("errors", -1).positive?
|
|
57
|
+
|
|
58
|
+
@weights = weights.reject { |_name, weight| weight.zero? }
|
|
59
|
+
raise Error, "weighted_score: every weight is zero" if @weights.empty?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A column no row has does not count. A row missing a column the others
|
|
63
|
+
# have gets nil: it was not measured on everything they were.
|
|
64
|
+
#
|
|
65
|
+
# @param rows [Array<Hash{String => Numeric, nil}>] one per contender, compared with each other
|
|
66
|
+
# @return [Array<Float, nil>] in the order given
|
|
67
|
+
def call(rows)
|
|
68
|
+
present = weights.select { |name, _weight| rows.any? { it[name] } }
|
|
69
|
+
total = present.values.sum(&:abs)
|
|
70
|
+
rows.map do |row|
|
|
71
|
+
next if present.empty? || present.keys.any? { row[it].nil? }
|
|
72
|
+
|
|
73
|
+
score = present.sum { |name, weight| weight.abs * goodness(name, row[name], weight, rows) } / total
|
|
74
|
+
[score - penalty(row["errors"], present), 0.0].max
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [String] the formula, with this lineup's weights, for the glossary
|
|
79
|
+
def description
|
|
80
|
+
"Σ(|weight| × goodness) / Σ|weight|, from 0% to 100%, and higher is better. A negative weight means that column is better low. " \
|
|
81
|
+
"Errors come off the total: 20 points for one, then 5 more for each further error. " \
|
|
82
|
+
"#{MAGNITUDES.join(", ")} are on a log scale within the group, so compare wscore within one table only. " \
|
|
83
|
+
"Weights: #{weights.map { |name, weight| "#{name} #{weight}" }.join(", ")}."
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def penalty(errors, present)
|
|
89
|
+
return 0.0 unless present.key?("errors") && errors.positive?
|
|
90
|
+
|
|
91
|
+
ERROR_BASE + (ERROR_STEP * errors)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def goodness(name, value, weight, rows)
|
|
95
|
+
return 1.0 if name == "errors"
|
|
96
|
+
|
|
97
|
+
position = MAGNITUDES.include?(name) ? position(value, rows.filter_map { it[name] }) : value.to_f
|
|
98
|
+
return 1.0 if position.nil?
|
|
99
|
+
|
|
100
|
+
weight.negative? ? 1.0 - position : position
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A zero has no logarithm, so it stands at half the lowest above zero.
|
|
104
|
+
#
|
|
105
|
+
# @return [Float, nil] from 0 at the lowest to 1 at the highest; nil when all are equal
|
|
106
|
+
def position(value, values)
|
|
107
|
+
highest = values.max
|
|
108
|
+
return nil if values.min == highest
|
|
109
|
+
|
|
110
|
+
lowest = values.select(&:positive?).min
|
|
111
|
+
lowest /= 2.0 if values.any?(&:zero?)
|
|
112
|
+
(Math.log([value, lowest].max) - Math.log(lowest)) / (Math.log(highest) - Math.log(lowest))
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/llm/evals.rb
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require "json"
|
|
5
|
+
require "dry/cli"
|
|
6
|
+
require "dry/cli/help"
|
|
7
|
+
require "dry/cli/ui"
|
|
8
|
+
require "dry/cli/autocomplete/command"
|
|
9
|
+
require "zeitwerk"
|
|
10
|
+
|
|
11
|
+
module LLM
|
|
12
|
+
# A generic evaluation runner with a one-way boundary. The consumer suite
|
|
13
|
+
# registers an {Adapter}, which runs one {Case} against a {Model} and
|
|
14
|
+
# returns a {Reply} without scoring it, and a {Scorer}, which turns a reply
|
|
15
|
+
# into a {Score} without calling anything. Everything else, from discovery
|
|
16
|
+
# to the leaderboard, is this gem's, and none of it knows what is being
|
|
17
|
+
# evaluated.
|
|
18
|
+
module Evals
|
|
19
|
+
class Error < StandardError; end
|
|
20
|
+
|
|
21
|
+
# Raised by a command that needs an optional gem the bundle does not hold.
|
|
22
|
+
class MissingDependency < Error; end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# The suite's settings, filled in by {.configure} from `evals/eval_helper.rb`.
|
|
26
|
+
#
|
|
27
|
+
# @return [Configuration]
|
|
28
|
+
def configuration
|
|
29
|
+
@configuration ||= Configuration.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Builds the suite's configuration. Called once from the eval helper.
|
|
33
|
+
#
|
|
34
|
+
# @example evals/eval_helper.rb
|
|
35
|
+
# LLM::Evals.configure do |c|
|
|
36
|
+
# c.suite "extract", root: __dir__, env_files: ["../.env"]
|
|
37
|
+
# c.cases "datasets/*/cases/**/input.txt"
|
|
38
|
+
# c.adapter ExtractAdapter.new
|
|
39
|
+
# c.scorer ExtractScorer.new
|
|
40
|
+
# c.before_run { MyGem.reset_configuration! }
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# @yieldparam configuration [Configuration]
|
|
44
|
+
# @return [Configuration]
|
|
45
|
+
def configure
|
|
46
|
+
yield configuration
|
|
47
|
+
configuration
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Forgets the configuration, so a spec that loads a second helper starts clean.
|
|
51
|
+
#
|
|
52
|
+
# @return [void]
|
|
53
|
+
def reset!
|
|
54
|
+
@configuration = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [Boolean] whether a helper has registered an adapter and a scorer
|
|
58
|
+
def configured? = configuration.complete?
|
|
59
|
+
|
|
60
|
+
# Requires an optional gem the moment a command needs it, and names it
|
|
61
|
+
# when it is missing, instead of letting a bare LoadError surface three
|
|
62
|
+
# frames deep in a provider.
|
|
63
|
+
#
|
|
64
|
+
# @param feature [String] what to require, e.g. "ruby_llm"
|
|
65
|
+
# @param gem_name [String] the gem to add to the Gemfile; the feature when they agree
|
|
66
|
+
# @param needed_for [String] what the user was doing
|
|
67
|
+
# @return [void]
|
|
68
|
+
# @raise [MissingDependency]
|
|
69
|
+
def optional!(feature, gem_name = feature, needed_for:)
|
|
70
|
+
require feature
|
|
71
|
+
rescue LoadError
|
|
72
|
+
raise MissingDependency, "#{needed_for} needs the #{gem_name} gem: add `gem \"#{gem_name}\"` to your Gemfile"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
require_relative "evals/version"
|
|
79
|
+
|
|
80
|
+
loader = Zeitwerk::Loader.for_gem_extension(LLM)
|
|
81
|
+
loader.inflector.inflect("cli" => "CLI", "html" => "HTML", "json" => "JSON")
|
|
82
|
+
loader.ignore(File.expand_path("evals/version.rb", __dir__))
|
|
83
|
+
loader.setup
|