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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +26 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +764 -0
  5. data/exe/evals +9 -0
  6. data/exe/llm-evals +14 -0
  7. data/lib/llm/evals/adapter.rb +47 -0
  8. data/lib/llm/evals/case.rb +36 -0
  9. data/lib/llm/evals/cli/cases.rb +48 -0
  10. data/lib/llm/evals/cli/command.rb +163 -0
  11. data/lib/llm/evals/cli/compare.rb +40 -0
  12. data/lib/llm/evals/cli/formatting.rb +16 -0
  13. data/lib/llm/evals/cli/leaderboard.rb +183 -0
  14. data/lib/llm/evals/cli/model_colors.rb +23 -0
  15. data/lib/llm/evals/cli/model_command.rb +437 -0
  16. data/lib/llm/evals/cli/progress_bars.rb +147 -0
  17. data/lib/llm/evals/cli/render.rb +37 -0
  18. data/lib/llm/evals/cli/run.rb +69 -0
  19. data/lib/llm/evals/cli/standings.rb +114 -0
  20. data/lib/llm/evals/cli/verify.rb +24 -0
  21. data/lib/llm/evals/cli/version.rb +15 -0
  22. data/lib/llm/evals/cli.rb +43 -0
  23. data/lib/llm/evals/completion.rb +11 -0
  24. data/lib/llm/evals/config.rb +198 -0
  25. data/lib/llm/evals/configuration.rb +173 -0
  26. data/lib/llm/evals/contender.rb +31 -0
  27. data/lib/llm/evals/discovery.rb +95 -0
  28. data/lib/llm/evals/experiment.rb +158 -0
  29. data/lib/llm/evals/helper.rb +98 -0
  30. data/lib/llm/evals/launcher.rb +37 -0
  31. data/lib/llm/evals/metric.rb +26 -0
  32. data/lib/llm/evals/model.rb +55 -0
  33. data/lib/llm/evals/providers/base.rb +93 -0
  34. data/lib/llm/evals/providers/catalog.rb +32 -0
  35. data/lib/llm/evals/providers/open_router.rb +64 -0
  36. data/lib/llm/evals/providers/registry.rb +112 -0
  37. data/lib/llm/evals/providers/type_safe.rb +53 -0
  38. data/lib/llm/evals/render/reports/html.rb +266 -0
  39. data/lib/llm/evals/render/reports/json.rb +172 -0
  40. data/lib/llm/evals/render/reports/pipeline.rb +153 -0
  41. data/lib/llm/evals/render/reports/terminal.rb +46 -0
  42. data/lib/llm/evals/reply.rb +18 -0
  43. data/lib/llm/evals/run_result.rb +14 -0
  44. data/lib/llm/evals/runtime.rb +74 -0
  45. data/lib/llm/evals/schema_validator.rb +84 -0
  46. data/lib/llm/evals/score.rb +26 -0
  47. data/lib/llm/evals/scorer.rb +45 -0
  48. data/lib/llm/evals/templates/html_report.css +206 -0
  49. data/lib/llm/evals/templates/html_report.html.erb +184 -0
  50. data/lib/llm/evals/templates/html_report.js +48 -0
  51. data/lib/llm/evals/testing/echo_adapter.rb +29 -0
  52. data/lib/llm/evals/testing/fake_model.rb +18 -0
  53. data/lib/llm/evals/testing/fake_provider.rb +67 -0
  54. data/lib/llm/evals/tracker.rb +80 -0
  55. data/lib/llm/evals/trackers/braintrust.rb +91 -0
  56. data/lib/llm/evals/trackers/local.rb +78 -0
  57. data/lib/llm/evals/trackers/logfire.rb +143 -0
  58. data/lib/llm/evals/trackers/registry.rb +82 -0
  59. data/lib/llm/evals/usage.rb +33 -0
  60. data/lib/llm/evals/verify.rb +49 -0
  61. data/lib/llm/evals/version.rb +7 -0
  62. data/lib/llm/evals/warnings.rb +98 -0
  63. data/lib/llm/evals/weighted_score.rb +116 -0
  64. data/lib/llm/evals.rb +83 -0
  65. 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ VERSION = "0.4.0"
6
+ end
7
+ 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