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/experiment.rb
DELETED
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LLM
|
|
4
|
-
module Evals
|
|
5
|
-
# One model at one setting over every case of one dataset, run on a
|
|
6
|
-
# thread pool the gem owns, plus the per-case detail the terminal report
|
|
7
|
-
# needs. Trackers only watch: {Trackers::Local} always, and at most one
|
|
8
|
-
# remote tracker, each told when the experiment starts, when a case
|
|
9
|
-
# finishes, and what the summary was.
|
|
10
|
-
class Experiment
|
|
11
|
-
# What one finished experiment reports back. `means` holds one entry
|
|
12
|
-
# per metric plus "score", the mean of each case's overall value;
|
|
13
|
-
# `usage` is summed over the cases; `permalink` is the remote tracker's
|
|
14
|
-
# page, when it has one.
|
|
15
|
-
Summary = Data.define(:model, :dataset, :name, :means, :results, :errors, :duration, :permalink, :usage) do
|
|
16
|
-
# @return [Float, nil] the mean overall score
|
|
17
|
-
def score = means["score"]
|
|
18
|
-
|
|
19
|
-
# @param metric [Metric, String]
|
|
20
|
-
# @return [Float, nil]
|
|
21
|
-
def mean(metric) = means[metric.respond_to?(:name) ? metric.name : metric.to_s]
|
|
22
|
-
|
|
23
|
-
# The same experiment over one variant's cases. Its duration is the sum
|
|
24
|
-
# of those cases' own, since they overlapped with the others.
|
|
25
|
-
#
|
|
26
|
-
# @param variant [String]
|
|
27
|
-
# @param metrics [Array<Metric>] the scorer's
|
|
28
|
-
# @return [Summary]
|
|
29
|
-
def for_variant(variant, metrics)
|
|
30
|
-
chosen = results.select { it.case.variant == variant }
|
|
31
|
-
with(means: Experiment.means(metrics, chosen),
|
|
32
|
-
results: chosen,
|
|
33
|
-
errors: chosen.select(&:failed?).map { "#{it.case.key}: #{it.error}" },
|
|
34
|
-
duration: chosen.sum(&:duration),
|
|
35
|
-
usage: Experiment.usage(chosen))
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# @param metrics [Array<Metric>]
|
|
40
|
-
# @param results [Array<RunResult>]
|
|
41
|
-
# @return [Hash{String => Float, nil}] one mean per metric, plus "score"
|
|
42
|
-
def self.means(metrics, results)
|
|
43
|
-
scored = results.filter_map(&:score)
|
|
44
|
-
means = metrics.to_h { |metric| [metric.name, metric.combine(scored.map { it.metrics[metric.name] })] }
|
|
45
|
-
means.merge("score" => scored.empty? ? nil : scored.sum(&:value).fdiv(scored.size))
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# @param results [Array<RunResult>]
|
|
49
|
-
# @return [Usage] summed over the results
|
|
50
|
-
def self.usage(results) = results.filter_map(&:reply).map(&:usage).reduce(Usage.zero, :+)
|
|
51
|
-
|
|
52
|
-
attr_reader :model, :adapter, :scorer, :cases, :dataset, :parallelism, :trackers
|
|
53
|
-
|
|
54
|
-
# @param model [Model]
|
|
55
|
-
# @param cases [Array<Case>] all of one dataset
|
|
56
|
-
# @param adapter [Adapter]
|
|
57
|
-
# @param scorer [Scorer]
|
|
58
|
-
# @param parallelism [Integer] cases in flight at once
|
|
59
|
-
# @param trackers [Array<Tracker>] told about the run, in this order
|
|
60
|
-
def initialize(model:, cases:, adapter:, scorer:, parallelism: 4, trackers: [])
|
|
61
|
-
raise ArgumentError, "an experiment needs at least one case" if cases.empty?
|
|
62
|
-
|
|
63
|
-
@model = model
|
|
64
|
-
@cases = cases.sort_by { [it.variant, it.number || 0, it.id] }
|
|
65
|
-
@dataset = cases.first.dataset
|
|
66
|
-
@adapter = adapter
|
|
67
|
-
@scorer = scorer
|
|
68
|
-
@parallelism = [parallelism.to_i, 1].max
|
|
69
|
-
@trackers = trackers.select(&:enabled?)
|
|
70
|
-
@results = {}
|
|
71
|
-
@lock = Mutex.new
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# @return [String] e.g. "anthropic/claude-haiku-5.5 effort=low t=0.0 [clear]"
|
|
75
|
-
def name = "#{model} [#{dataset}]"
|
|
76
|
-
|
|
77
|
-
# @param on_case [#call, nil] called once per finished case
|
|
78
|
-
# @return [Summary]
|
|
79
|
-
def run(on_case: nil)
|
|
80
|
-
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
81
|
-
adapter.prepare(dataset)
|
|
82
|
-
trackers.each { it.start(self) }
|
|
83
|
-
errors = run_locally(on_case)
|
|
84
|
-
summary = summarize(errors, nil, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
|
|
85
|
-
urls = trackers.map { it.finish(self, summary)&.fetch(:url, nil) }
|
|
86
|
-
summary.with(permalink: urls.compact.first)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# Runs one case: the adapter, then the scorer. A reply the model failed
|
|
90
|
-
# (`Reply#error`) scores 0 without troubling the scorer; an exception is
|
|
91
|
-
# the transport's and is left to the caller, which counts it as an error.
|
|
92
|
-
#
|
|
93
|
-
# @param kase [Case]
|
|
94
|
-
# @return [RunResult]
|
|
95
|
-
def evaluate(kase)
|
|
96
|
-
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
97
|
-
reply = adapter.call(kase, model: model)
|
|
98
|
-
raise Error, "#{adapter.class}#call returned #{reply.class}, not an LLM::Evals::Reply" unless reply.is_a?(Reply)
|
|
99
|
-
|
|
100
|
-
score = reply.error? ? Score.failed(scorer.metrics, reply.error) : scorer.call(kase, reply, context: adapter.context(kase))
|
|
101
|
-
RunResult.new(experiment: self,
|
|
102
|
-
case: kase,
|
|
103
|
-
reply: reply,
|
|
104
|
-
score: score,
|
|
105
|
-
duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
private
|
|
109
|
-
|
|
110
|
-
# Remembers a result and tells the trackers, under one lock, so a
|
|
111
|
-
# tracker sees results one at a time and in the order they are kept.
|
|
112
|
-
def remember(result)
|
|
113
|
-
@lock.synchronize do
|
|
114
|
-
@results[result.case.key] = result
|
|
115
|
-
trackers.each { it.record(result) }
|
|
116
|
-
end
|
|
117
|
-
result
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
# A fixed pool of threads over a queue: `parallelism` cases in flight.
|
|
121
|
-
def run_locally(on_case)
|
|
122
|
-
queue = Queue.new
|
|
123
|
-
cases.each { queue << it }
|
|
124
|
-
queue.close
|
|
125
|
-
errors = Queue.new
|
|
126
|
-
Array.new([parallelism, cases.size].min) do
|
|
127
|
-
Thread.new do
|
|
128
|
-
while (kase = queue.pop)
|
|
129
|
-
begin
|
|
130
|
-
remember evaluate(kase)
|
|
131
|
-
rescue StandardError => e
|
|
132
|
-
remember RunResult.new(experiment: self, case: kase, error: "#{e.class}: #{e.message}")
|
|
133
|
-
errors << "#{kase.key}: #{e.class}: #{e.message}"
|
|
134
|
-
end
|
|
135
|
-
on_case&.call
|
|
136
|
-
end
|
|
137
|
-
end
|
|
138
|
-
end.each(&:join)
|
|
139
|
-
Array.new(errors.size) { errors.pop }
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def summarize(errors, permalink, duration)
|
|
143
|
-
results = @lock.synchronize { @results.values }.sort_by { cases.index(it.case) }
|
|
144
|
-
Summary.new(
|
|
145
|
-
model: model,
|
|
146
|
-
dataset: dataset,
|
|
147
|
-
name: name,
|
|
148
|
-
means: self.class.means(scorer.metrics, results),
|
|
149
|
-
results: results,
|
|
150
|
-
errors: Array(errors),
|
|
151
|
-
duration: duration,
|
|
152
|
-
permalink: permalink,
|
|
153
|
-
usage: self.class.usage(results)
|
|
154
|
-
)
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
end
|
data/lib/llm/evals/helper.rb
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LLM
|
|
4
|
-
module Evals
|
|
5
|
-
# Finds and loads the suite: `evals/eval_helper.rb`, the file that calls
|
|
6
|
-
# {LLM::Evals.configure}, or, for a config-only suite, the directory
|
|
7
|
-
# holding `evals/.llm-evals.config.yml`. The executable looks before
|
|
8
|
-
# dispatching, the way rspec finds spec_helper, so the suite's own
|
|
9
|
-
# directory needs no wrapper script.
|
|
10
|
-
module Helper
|
|
11
|
-
FILE = File.join("evals", "eval_helper.rb")
|
|
12
|
-
ENV_VAR = "LLM_EVALS_HELPER"
|
|
13
|
-
FLAG = "--helper"
|
|
14
|
-
|
|
15
|
-
# What {.locate} found: a helper to load, a config file to read, or neither.
|
|
16
|
-
Found = Data.define(:helper, :config) do
|
|
17
|
-
def initialize(helper: nil, config: nil) = super
|
|
18
|
-
|
|
19
|
-
# @return [Boolean] whether there is anything to load
|
|
20
|
-
def any? = !helper.nil? || !config.nil?
|
|
21
|
-
|
|
22
|
-
# @return [Boolean]
|
|
23
|
-
def none? = !any?
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
module_function
|
|
27
|
-
|
|
28
|
-
# Pulls `--helper PATH` (or `--helper=PATH`) out of argv, so dry-cli
|
|
29
|
-
# never sees a flag no command declares.
|
|
30
|
-
#
|
|
31
|
-
# @param argv [Array<String>]
|
|
32
|
-
# @return [Array(Array<String>, String, nil)] the remaining arguments and the path, if given
|
|
33
|
-
def extract(argv)
|
|
34
|
-
rest = []
|
|
35
|
-
path = nil
|
|
36
|
-
skip = false
|
|
37
|
-
argv.each_with_index do |arg, index|
|
|
38
|
-
next skip = false if skip
|
|
39
|
-
|
|
40
|
-
if arg == FLAG
|
|
41
|
-
path = argv[index + 1]
|
|
42
|
-
skip = true
|
|
43
|
-
elsif arg.start_with?("#{FLAG}=")
|
|
44
|
-
path = arg.delete_prefix("#{FLAG}=")
|
|
45
|
-
else
|
|
46
|
-
rest << arg
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
[rest, path]
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# The suite to load: the flag, then the variable, then, from the
|
|
53
|
-
# directory upward, the nearest `evals/eval_helper.rb` or, failing
|
|
54
|
-
# that, the nearest `evals/.llm-evals.config.yml` (also found from
|
|
55
|
-
# inside `evals/` itself).
|
|
56
|
-
#
|
|
57
|
-
# @param explicit [String, nil] what `--helper` was given
|
|
58
|
-
# @param from [String] the directory to search up from
|
|
59
|
-
# @param env [Hash] the environment
|
|
60
|
-
# @return [Found] absolute paths; empty when there is none
|
|
61
|
-
def locate(explicit = nil, from: Dir.pwd, env: ENV)
|
|
62
|
-
candidate = explicit || env[ENV_VAR]
|
|
63
|
-
return Found.new(helper: File.expand_path(candidate, from)) if candidate && !candidate.empty?
|
|
64
|
-
|
|
65
|
-
Pathname(from).expand_path.ascend do |dir|
|
|
66
|
-
helper = dir.join(FILE)
|
|
67
|
-
config = [dir.join("evals", Configuration::DEFAULT_CONFIG), dir.join(Configuration::DEFAULT_CONFIG)].find(&:file?)
|
|
68
|
-
return Found.new(helper: helper.to_s, config: config&.to_s) if helper.file?
|
|
69
|
-
return Found.new(config: config.to_s) if config
|
|
70
|
-
end
|
|
71
|
-
Found.new
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# Loads the helper, once, or roots the configuration at a config-only
|
|
75
|
-
# suite's directory so {Configuration#resolve_from_config!} can read it.
|
|
76
|
-
# A helper is loaded first and wins; the config fills what it left unset.
|
|
77
|
-
#
|
|
78
|
-
# @param found [Found, String, nil] nothing is loaded for nil or an empty Found
|
|
79
|
-
# @param configuration [Configuration]
|
|
80
|
-
# @return [Boolean] whether anything was loaded
|
|
81
|
-
# @raise [Error] when the helper does not exist
|
|
82
|
-
def load!(found, configuration: LLM::Evals.configuration)
|
|
83
|
-
found = Found.new(helper: found) if found.is_a?(String)
|
|
84
|
-
return false if found.nil? || found.none?
|
|
85
|
-
|
|
86
|
-
if found.helper
|
|
87
|
-
raise Error, "no eval helper at #{found.helper}" unless File.file?(found.helper)
|
|
88
|
-
|
|
89
|
-
require File.expand_path(found.helper)
|
|
90
|
-
else
|
|
91
|
-
root = File.dirname(found.config)
|
|
92
|
-
configuration.suite(File.basename(File.dirname(root)), root: root) unless configuration.complete?
|
|
93
|
-
end
|
|
94
|
-
true
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
end
|
data/lib/llm/evals/metric.rb
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LLM
|
|
4
|
-
module Evals
|
|
5
|
-
# One number a {Scorer} reports per case. `aggregate` is how the run
|
|
6
|
-
# combines them (only :mean today), skipping nil, so a metric a case does
|
|
7
|
-
# not apply to never drags its mean down with a free 0 or up with a free 1.
|
|
8
|
-
# The `primary` metric ranks the leaderboard and picks the best value.
|
|
9
|
-
class Metric < Data.define(:name, :description, :aggregate, :primary)
|
|
10
|
-
AGGREGATES = %i[mean].freeze
|
|
11
|
-
|
|
12
|
-
def initialize(name:, description: "", aggregate: :mean, primary: false)
|
|
13
|
-
raise ArgumentError, "aggregate must be one of #{AGGREGATES.join(", ")}" unless AGGREGATES.include?(aggregate)
|
|
14
|
-
|
|
15
|
-
super(name: name.to_s, description: description, aggregate: aggregate, primary: primary)
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
# @param values [Array<Float, nil>] one per case
|
|
19
|
-
# @return [Float, nil] nil when no case reported the metric
|
|
20
|
-
def combine(values)
|
|
21
|
-
present = values.compact
|
|
22
|
-
present.sum.fdiv(present.size) unless present.empty?
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
data/lib/llm/evals/model.rb
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LLM
|
|
4
|
-
module Evals
|
|
5
|
-
# One model at one setting, reached through its provider. Adapters call
|
|
6
|
-
# {#complete}; the settings an experiment is named by are read here.
|
|
7
|
-
class Model
|
|
8
|
-
# The settings every provider understands; a provider may accept more.
|
|
9
|
-
SETTINGS = %i[effort temperature max_tokens].freeze
|
|
10
|
-
|
|
11
|
-
attr_reader :id, :provider, :settings
|
|
12
|
-
|
|
13
|
-
# @param id [String] the model id as the lineup names it, e.g. "anthropic/claude-haiku-5.5"
|
|
14
|
-
# @param provider [Providers::Base] what answers {#complete}
|
|
15
|
-
# @param settings [Hash{Symbol => Object}] effort, temperature, max_tokens; nil leaves the model's default
|
|
16
|
-
def initialize(id:, provider:, settings: {})
|
|
17
|
-
@id = id
|
|
18
|
-
@provider = provider
|
|
19
|
-
@settings = settings.transform_keys(&:to_sym).freeze
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# @return [String, nil] reasoning effort; nil sends none
|
|
23
|
-
def effort = settings[:effort]
|
|
24
|
-
|
|
25
|
-
# @return [Float, nil] sampling temperature; nil sends none
|
|
26
|
-
def temperature = settings[:temperature]
|
|
27
|
-
|
|
28
|
-
# @return [Integer, nil] output token cap
|
|
29
|
-
def max_tokens = settings[:max_tokens]
|
|
30
|
-
|
|
31
|
-
# @return [String] the id without the provider's prefix, as the provider's API knows it
|
|
32
|
-
def name = provider.model_name(id)
|
|
33
|
-
|
|
34
|
-
# @return [String] "provider:id", unique where two providers render the same id
|
|
35
|
-
def key = "#{provider.name}:#{id}"
|
|
36
|
-
|
|
37
|
-
# One call. Transport failures raise; they are the run's errors, not the model's.
|
|
38
|
-
#
|
|
39
|
-
# @param system [String, nil]
|
|
40
|
-
# @param user [String]
|
|
41
|
-
# @param json [Boolean] ask the provider for a JSON object reply where it supports that
|
|
42
|
-
# @return [Completion]
|
|
43
|
-
def complete(user:, system: nil, json: true)
|
|
44
|
-
provider.complete(self, system: system, user: user, json: json)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# @return [String] e.g. "effort=low t=0.2"
|
|
48
|
-
def label
|
|
49
|
-
"effort=#{effort || "default"} t=#{temperature.nil? ? "default" : temperature}"
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def to_s = "#{id} #{label}"
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
data/lib/llm/evals/reply.rb
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LLM
|
|
4
|
-
module Evals
|
|
5
|
-
# What an {Adapter} returns for one case. `error` is the model's failure
|
|
6
|
-
# (it did not answer the question: no JSON, a refusal) and scores 0; a
|
|
7
|
-
# transport failure is not the model's and is raised instead, so it is
|
|
8
|
-
# counted as an error of the run rather than a wrong answer.
|
|
9
|
-
Reply = Data.define(:output, :raw, :usage, :error, :metadata) do
|
|
10
|
-
def initialize(output: nil, raw: nil, usage: Usage.zero, error: nil, metadata: {})
|
|
11
|
-
super(output: output, raw: raw, usage: Usage.from(usage), error: error, metadata: metadata)
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
# @return [Boolean] whether the model failed to answer
|
|
15
|
-
def error? = !error.nil?
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
data/lib/llm/evals/run_result.rb
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LLM
|
|
4
|
-
module Evals
|
|
5
|
-
# One case, run once: what came back and what it scored. `reply` is nil
|
|
6
|
-
# when the adapter raised, and `error` then holds the message.
|
|
7
|
-
RunResult = Data.define(:experiment, :case, :reply, :score, :duration, :error) do
|
|
8
|
-
def initialize(experiment:, case:, reply: nil, score: nil, duration: 0.0, error: nil) = super
|
|
9
|
-
|
|
10
|
-
# @return [Boolean] whether the adapter raised rather than answered
|
|
11
|
-
def failed? = !error.nil?
|
|
12
|
-
end
|
|
13
|
-
end
|
|
14
|
-
end
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LLM
|
|
4
|
-
module Evals
|
|
5
|
-
# Checks a Hash of answers against a field map, for scorers and verifiers
|
|
6
|
-
# whose expected values are typed and closed. Nothing here is specific to
|
|
7
|
-
# what the fields mean.
|
|
8
|
-
#
|
|
9
|
-
# The map is `{ "name" => { type:, enum:, nullable: } }`; `type` is one of
|
|
10
|
-
# {TYPES}, `enum` lists the allowed values for enum and multi_enum, and a
|
|
11
|
-
# nullable field accepts nil (the text never answered it).
|
|
12
|
-
class SchemaValidator
|
|
13
|
-
TYPES = %i[boolean integer number string enum multi_enum].freeze
|
|
14
|
-
|
|
15
|
-
attr_reader :fields
|
|
16
|
-
|
|
17
|
-
# @param fields [Hash{String, Symbol => Hash}]
|
|
18
|
-
# @raise [ArgumentError] on a type outside {TYPES}, since a typo there would pass everything
|
|
19
|
-
def initialize(fields)
|
|
20
|
-
@fields = fields.to_h { |name, spec| [name.to_s, normalize(name, spec)] }.freeze
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# @return [Array<String>] the field names
|
|
24
|
-
def names = fields.keys
|
|
25
|
-
|
|
26
|
-
# @param values [Hash] String or Symbol keys
|
|
27
|
-
# @return [Array<String>] every problem found; empty when the values fit the map
|
|
28
|
-
def problems(values)
|
|
29
|
-
return ["#{values.inspect} is not a Hash"] unless values.is_a?(Hash)
|
|
30
|
-
|
|
31
|
-
actual = values.transform_keys(&:to_s)
|
|
32
|
-
missing = names - actual.keys
|
|
33
|
-
extra = actual.keys - names
|
|
34
|
-
problems = []
|
|
35
|
-
problems << "missing #{missing.join(", ")}" if missing.any?
|
|
36
|
-
problems << "unknown fields #{extra.join(", ")}" if extra.any?
|
|
37
|
-
fields.each do |name, spec|
|
|
38
|
-
next unless actual.key?(name)
|
|
39
|
-
|
|
40
|
-
problem = value_problem(spec, actual[name])
|
|
41
|
-
problems << "#{name}: #{problem}" if problem
|
|
42
|
-
end
|
|
43
|
-
problems
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# @param values [Hash]
|
|
47
|
-
# @return [Boolean]
|
|
48
|
-
def valid?(values) = problems(values).empty?
|
|
49
|
-
|
|
50
|
-
# @param spec [Hash] one field's normalized spec
|
|
51
|
-
# @param value [Object]
|
|
52
|
-
# @return [String, nil] what is wrong with the value, or nil
|
|
53
|
-
def value_problem(spec, value)
|
|
54
|
-
return (spec[:nullable] ? nil : "is null") if value.nil?
|
|
55
|
-
|
|
56
|
-
case spec[:type]
|
|
57
|
-
when :boolean then "#{value.inspect} is not a boolean" unless [true, false].include?(value)
|
|
58
|
-
when :integer then "#{value.inspect} is not an integer" unless value.is_a?(Integer)
|
|
59
|
-
when :number then "#{value.inspect} is not a number" unless value.is_a?(Numeric)
|
|
60
|
-
when :string then "#{value.inspect} is not a string" unless value.is_a?(String)
|
|
61
|
-
when :enum then "#{value.inspect} is not one of #{spec[:enum].join("|")}" unless spec[:enum].include?(value)
|
|
62
|
-
when :multi_enum then multi_enum_problem(spec, value)
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
private
|
|
67
|
-
|
|
68
|
-
def multi_enum_problem(spec, value)
|
|
69
|
-
return "#{value.inspect} is not an array" unless value.is_a?(Array)
|
|
70
|
-
|
|
71
|
-
stray = value - spec[:enum]
|
|
72
|
-
"#{stray.join(", ")} not in #{spec[:enum].join("|")}" if stray.any?
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def normalize(name, spec)
|
|
76
|
-
spec = spec.transform_keys(&:to_sym)
|
|
77
|
-
type = spec.fetch(:type).to_sym
|
|
78
|
-
raise ArgumentError, "#{name}: unknown type #{type.inspect}, expected one of #{TYPES.join(", ")}" unless TYPES.include?(type)
|
|
79
|
-
|
|
80
|
-
{ type: type, enum: Array(spec[:enum] || spec[:values]).map(&:to_s), nullable: spec.fetch(:nullable, false) }
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
data/lib/llm/evals/score.rb
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LLM
|
|
4
|
-
module Evals
|
|
5
|
-
# What a {Scorer} says about one reply. `value` is the case's overall
|
|
6
|
-
# score in 0..1; `metrics` holds one entry per {Metric} the scorer
|
|
7
|
-
# declares, nil where the case does not apply; `outcomes` and
|
|
8
|
-
# `diagnostics` are free-form, keyed by field, and only ever displayed.
|
|
9
|
-
Score = Data.define(:value, :metrics, :outcomes, :diagnostics) do
|
|
10
|
-
def initialize(value:, metrics: {}, outcomes: {}, diagnostics: {})
|
|
11
|
-
raise ArgumentError, "score value #{value.inspect} is not within 0..1" unless value.is_a?(Numeric) && (0..1).cover?(value)
|
|
12
|
-
|
|
13
|
-
super(value: value.to_f, metrics: metrics.transform_keys(&:to_s), outcomes: outcomes, diagnostics: diagnostics)
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
# The score of a reply the model failed: 0 everywhere, the failure as a diagnostic.
|
|
17
|
-
#
|
|
18
|
-
# @param metrics [Array<Metric>]
|
|
19
|
-
# @param error [String]
|
|
20
|
-
# @return [Score]
|
|
21
|
-
def self.failed(metrics, error)
|
|
22
|
-
new(value: 0.0, metrics: metrics.to_h { [it.name, 0.0] }, diagnostics: { "error" => error })
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
data/lib/llm/evals/scorer.rb
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LLM
|
|
4
|
-
module Evals
|
|
5
|
-
# What a suite implements to score one reply. Pure and deterministic: it
|
|
6
|
-
# reads the case, the reply and the adapter's context, and calls nothing.
|
|
7
|
-
#
|
|
8
|
-
# @abstract Subclass and override {#metrics} and {#call}.
|
|
9
|
-
class Scorer
|
|
10
|
-
# The metrics every {Score} this scorer returns carries. The CLI builds
|
|
11
|
-
# its columns, its glossary and its Braintrust scorers from this list,
|
|
12
|
-
# so a metric missing here is a metric nobody sees.
|
|
13
|
-
#
|
|
14
|
-
# @return [Array<Metric>]
|
|
15
|
-
def metrics
|
|
16
|
-
raise NotImplementedError, "#{self.class}#metrics must return an Array of LLM::Evals::Metric"
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
# @return [Metric] the one flagged primary, else the first
|
|
20
|
-
def primary = metrics.find(&:primary) || metrics.first
|
|
21
|
-
|
|
22
|
-
# @param kase [Case]
|
|
23
|
-
# @param reply [Reply] never one with `error` set; the runner scores those 0 itself
|
|
24
|
-
# @param context [Hash] what {Adapter#context} returned for the case
|
|
25
|
-
# @return [Score]
|
|
26
|
-
def call(kase, reply, context:)
|
|
27
|
-
raise NotImplementedError, "#{self.class}#call(kase, reply, context:) must return an LLM::Evals::Score"
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# Offline checks of one case against what the scorer will demand of it,
|
|
31
|
-
# so a typo in expected.json is caught before it is billed as a model's mistake.
|
|
32
|
-
#
|
|
33
|
-
# @param kase [Case]
|
|
34
|
-
# @param context [Hash] what {Adapter#context} returned for the case
|
|
35
|
-
# @return [Array<String>] problems; empty when the case is sound
|
|
36
|
-
def verify(kase, context:) = [] # rubocop:disable Lint/UnusedMethodArgument -- the signature is the contract
|
|
37
|
-
|
|
38
|
-
# One short cell describing the case in `llm-evals cases`, e.g. "5/8 answered".
|
|
39
|
-
#
|
|
40
|
-
# @param kase [Case]
|
|
41
|
-
# @return [String, nil] nil for the generic fallback
|
|
42
|
-
def summary(_kase) = nil
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
data/lib/llm/evals/tracker.rb
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module LLM
|
|
4
|
-
module Evals
|
|
5
|
-
# Where a run's results go besides the terminal. The gem always owns the
|
|
6
|
-
# run loop; a tracker only receives callbacks: {#start} once per
|
|
7
|
-
# experiment, {#record} once per finished case, {#finish} once with the
|
|
8
|
-
# summary. The local JSON log ({Trackers::Local}) always runs; at most
|
|
9
|
-
# one remote tracker runs beside it, chosen by `--tracker`, the
|
|
10
|
-
# `tracker:` key, or the environment (see {Trackers::Registry#choose}).
|
|
11
|
-
#
|
|
12
|
-
# A tracker for another framework (W&B Weave, an OTLP backend) subclasses
|
|
13
|
-
# this in a consumer or a plugin gem and is registered with `c.tracker`.
|
|
14
|
-
#
|
|
15
|
-
# @abstract Override the three callbacks; declare {.env_key} and {.gems}.
|
|
16
|
-
class Tracker
|
|
17
|
-
class << self
|
|
18
|
-
# @return [String] the name `--tracker` and `tracker:` use; the class name, dasherized, by default
|
|
19
|
-
def tracker_name = (name || "Tracker").split("::").last.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
|
|
20
|
-
|
|
21
|
-
# @return [String, nil] the environment variable whose presence selects this tracker when nothing else does
|
|
22
|
-
def env_key = nil
|
|
23
|
-
|
|
24
|
-
# @return [Array<String>] the optional gems this tracker requires when chosen
|
|
25
|
-
def gems = []
|
|
26
|
-
|
|
27
|
-
# @param env [Hash] the environment
|
|
28
|
-
# @return [Boolean] whether the environment names this tracker
|
|
29
|
-
def detect?(env = ENV) = !env_key.nil? && !env[env_key].to_s.strip.empty?
|
|
30
|
-
|
|
31
|
-
# Loads the gems and checks the key, with an error that names both, so
|
|
32
|
-
# a chosen tracker that cannot run says so before any model is called.
|
|
33
|
-
#
|
|
34
|
-
# @param env [Hash]
|
|
35
|
-
# @return [void]
|
|
36
|
-
# @raise [MissingDependency, Error]
|
|
37
|
-
def requirements!(env = ENV)
|
|
38
|
-
gems.each do |gem_name|
|
|
39
|
-
LLM::Evals.optional!(gem_name.tr("-", "/"), gem_name, needed_for: "the #{tracker_name} tracker")
|
|
40
|
-
end
|
|
41
|
-
return if env_key.nil? || detect?(env)
|
|
42
|
-
|
|
43
|
-
raise Error, "the #{tracker_name} tracker needs #{env_key} in the environment (gems: #{gems.join(", ")})"
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
attr_reader :context
|
|
48
|
-
|
|
49
|
-
# @param context [Hash] `suite:`, `project:`, `project_id:` and `options:` (the tracker's own key in the config file)
|
|
50
|
-
def initialize(**context)
|
|
51
|
-
@context = context
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# @return [String]
|
|
55
|
-
def name = self.class.tracker_name
|
|
56
|
-
|
|
57
|
-
# @return [Boolean] false turns every callback into a no-op
|
|
58
|
-
def enabled? = true
|
|
59
|
-
|
|
60
|
-
# @return [String] one line for `--verbose`: where this tracker sends things, and why
|
|
61
|
-
def describe = name
|
|
62
|
-
|
|
63
|
-
# @param experiment [Experiment]
|
|
64
|
-
# @return [void]
|
|
65
|
-
def start(_experiment) = nil
|
|
66
|
-
|
|
67
|
-
# @param result [RunResult]
|
|
68
|
-
# @return [void]
|
|
69
|
-
def record(_result) = nil
|
|
70
|
-
|
|
71
|
-
# @param experiment [Experiment]
|
|
72
|
-
# @param summary [Experiment::Summary]
|
|
73
|
-
# @return [Hash] `url:` to the experiment when one is known, else nil
|
|
74
|
-
def finish(_experiment, _summary) = { url: nil }
|
|
75
|
-
|
|
76
|
-
# @return [Hash] the tracker's own section of the config file
|
|
77
|
-
def options = context.fetch(:options, nil) || {}
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
end
|
data/lib/llm/evals/usage.rb
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
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
|