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,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module CLI
|
|
6
|
+
# One experiment per model and dataset, each with a per-case breakdown.
|
|
7
|
+
# Without -m, every model .llm-evals.config.yml lists. {Compare} runs the same
|
|
8
|
+
# experiments and prints only the leaderboard.
|
|
9
|
+
class Run < ModelCommand
|
|
10
|
+
desc "Run every case against a model, or every model in .llm-evals.config.yml, with a per-case breakdown"
|
|
11
|
+
|
|
12
|
+
example [
|
|
13
|
+
"-m anthropic/claude-haiku-5.5",
|
|
14
|
+
"-m openai/gpt-4.1-mini -t 0 -c clear/00 # one variant's first cases",
|
|
15
|
+
"-m google/gemini-2.5-flash -e high --local # score without Braintrust",
|
|
16
|
+
"-d colors,animals -o # both datasets; the breakdowns go to log/",
|
|
17
|
+
"-n # show what would run, call nothing"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
def call(models:, dataset:, dry_run:, **options)
|
|
21
|
+
config = lineup(options)
|
|
22
|
+
weigh(config)
|
|
23
|
+
contenders = contenders(config, models, options)
|
|
24
|
+
datasets = datasets(dataset)
|
|
25
|
+
return matrix(datasets, contenders) if dry_run
|
|
26
|
+
|
|
27
|
+
setup!(contenders, options)
|
|
28
|
+
runs = datasets.flat_map { |name| contenders.map { experiment(it, config, options, dataset: name) } }
|
|
29
|
+
summaries = execute(runs, concurrency: concurrency(options))
|
|
30
|
+
report(summaries,
|
|
31
|
+
output: options[:output],
|
|
32
|
+
open: options[:open],
|
|
33
|
+
scores: options[:scores],
|
|
34
|
+
format: options[:format],
|
|
35
|
+
config:,
|
|
36
|
+
options:,
|
|
37
|
+
run_options: serialize_run_options(config:, contenders:, datasets:, runs:, models:, dataset:, options:)) do |display_summaries|
|
|
38
|
+
display_summaries.each { breakdown(it, titled: display_summaries.size > 1) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# One row per case: its score, and whatever the scorer diagnosed.
|
|
45
|
+
def breakdown(summary, titled: false)
|
|
46
|
+
status_box :info, summary.name if titled
|
|
47
|
+
rows = summary.results.map do |result|
|
|
48
|
+
[result.case.id, result.case.difficulty, result.score ? percent(result.score.value) : "-", diagnosis(result)]
|
|
49
|
+
end
|
|
50
|
+
table(rows, header: %w[case difficulty score diagnostics], fill: 3, wrap: true)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The scorer's diagnostics, one per line, rendered without knowing
|
|
54
|
+
# their shape: "field: value" for a Hash, each item for an Array.
|
|
55
|
+
def diagnosis(result)
|
|
56
|
+
return result.error if result.failed?
|
|
57
|
+
|
|
58
|
+
result.score.diagnostics.map do |field, detail|
|
|
59
|
+
case detail
|
|
60
|
+
when Hash then "#{field}: #{detail.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}"
|
|
61
|
+
when Array then "#{field}: #{detail.join(", ")}"
|
|
62
|
+
else "#{field}: #{detail}"
|
|
63
|
+
end
|
|
64
|
+
end.join("\n")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module CLI
|
|
6
|
+
# Who ranks where: the arithmetic behind {Leaderboard}, which prints nothing.
|
|
7
|
+
class Standings
|
|
8
|
+
# How close to the best primary metric a model must come to compete on
|
|
9
|
+
# price: 0.02 is two percentage points.
|
|
10
|
+
VALUE_MARGIN = 0.02
|
|
11
|
+
|
|
12
|
+
attr_reader :metrics, :primary, :weighted
|
|
13
|
+
|
|
14
|
+
# @param metrics [Array<Metric>]
|
|
15
|
+
# @param primary [Metric] the metric the ranking reads
|
|
16
|
+
# @param weighted [WeightedScore, nil] nil when the file sets no weights
|
|
17
|
+
def initialize(metrics:, primary:, weighted: nil)
|
|
18
|
+
@metrics = metrics
|
|
19
|
+
@primary = primary
|
|
20
|
+
@weighted = weighted
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The best value: the cheapest entry within {VALUE_MARGIN} of the best
|
|
24
|
+
# primary metric. Entries with errors, or no score, do not compete. A
|
|
25
|
+
# plain score-per-dollar ratio would crown whatever is nearly free,
|
|
26
|
+
# however wrong it is.
|
|
27
|
+
#
|
|
28
|
+
# @param entries [Array<Array(Float, Float, Integer)>] primary metric, cost, errors
|
|
29
|
+
# @return [Integer, nil] the winner's index
|
|
30
|
+
def best_value(entries)
|
|
31
|
+
eligible = entries.each_index.select { entries[it][0] && entries[it][2].zero? }
|
|
32
|
+
return nil if eligible.empty?
|
|
33
|
+
|
|
34
|
+
floor = eligible.map { entries[it][0] }.max - VALUE_MARGIN
|
|
35
|
+
eligible.select { entries[it][0] >= floor - 1e-9 }.min_by { entries[it][1] }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Each contender's numbers over the given experiments, best first.
|
|
39
|
+
#
|
|
40
|
+
# @param summaries [Array<Experiment::Summary>]
|
|
41
|
+
# @return [Array<Hash>]
|
|
42
|
+
def contender_groups(summaries)
|
|
43
|
+
groups = summaries.group_by { [it.model.key, it.model.effort, it.model.temperature] }.values.map do |group|
|
|
44
|
+
values = group.filter_map { it.mean(primary) }
|
|
45
|
+
{ first: group.first, errors: group.sum { it.errors.size },
|
|
46
|
+
value: values.size == group.size ? values.sum / values.size : nil,
|
|
47
|
+
usage: group.map(&:usage).reduce(Usage.zero, :+),
|
|
48
|
+
row: overall_row(group) }
|
|
49
|
+
end
|
|
50
|
+
scores = weighted_by(groups.map { it[:row] })
|
|
51
|
+
groups.each_with_index { |g, index| g[:weighted] = scores[index] }
|
|
52
|
+
groups.sort_by { -((weighted ? it[:weighted] : it[:value]) || -1) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param groups [Array<Hash>] from {#contender_groups}
|
|
56
|
+
# @return [Integer, nil] the index of the best value
|
|
57
|
+
def best_contender(groups) = best_value(groups.map { [it[:value], it[:usage].cost_usd, it[:errors]] })
|
|
58
|
+
|
|
59
|
+
# @param summaries [Array<Experiment::Summary>]
|
|
60
|
+
# @return [Hash{Experiment::Summary => Float, nil}] by identity, each compared within its dataset
|
|
61
|
+
def weighted_scores(summaries)
|
|
62
|
+
summaries.group_by(&:dataset).values.each_with_object({}.compare_by_identity) do |group, scores|
|
|
63
|
+
group.zip(weighted_by(group.map { weighted_row(it) })).each { |summary, value| scores[summary] = value }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The best value in each dataset's block of the leaderboard.
|
|
68
|
+
#
|
|
69
|
+
# @param ranked [Array<Experiment::Summary>]
|
|
70
|
+
# @return [Array<Integer>] indices into ranked
|
|
71
|
+
def best_per_dataset(ranked)
|
|
72
|
+
ranked.each_index.group_by { ranked[it].dataset }.values.filter_map do |indices|
|
|
73
|
+
winner = best_value(indices.map { [ranked[it].mean(primary), ranked[it].usage.cost_usd, ranked[it].errors.size] })
|
|
74
|
+
winner && indices[winner]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# What {WeightedScore} reads from one experiment.
|
|
81
|
+
#
|
|
82
|
+
# @param summary [Experiment::Summary]
|
|
83
|
+
# @return [Hash{String => Numeric, nil}]
|
|
84
|
+
def weighted_row(summary)
|
|
85
|
+
metrics.to_h { [it.name, summary.mean(it)] }.merge(
|
|
86
|
+
"score" => summary.score,
|
|
87
|
+
"errors" => summary.errors.size,
|
|
88
|
+
"time" => summary.duration,
|
|
89
|
+
"cost" => summary.usage.cost_usd.to_f
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# One contender over every dataset: errors and magnitudes add up, the rest average.
|
|
94
|
+
#
|
|
95
|
+
# @param group [Array<Experiment::Summary>]
|
|
96
|
+
# @return [Hash{String => Numeric, nil}]
|
|
97
|
+
def overall_row(group)
|
|
98
|
+
rows = group.map { weighted_row(it) }
|
|
99
|
+
rows.first.keys.to_h do |name|
|
|
100
|
+
values = rows.map { it[name] }
|
|
101
|
+
total = values.sum(&:to_f)
|
|
102
|
+
next [name, nil] if values.any?(&:nil?)
|
|
103
|
+
|
|
104
|
+
[name, ["errors", *WeightedScore::MAGNITUDES].include?(name) ? total : total / values.size]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @param rows [Array<Hash>]
|
|
109
|
+
# @return [Array<Float, nil>] nils when the file sets no weights
|
|
110
|
+
def weighted_by(rows) = weighted ? weighted.call(rows) : Array.new(rows.size)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module CLI
|
|
6
|
+
# Offline checks that every case agrees with what its suite demands of it.
|
|
7
|
+
class Verify < Command
|
|
8
|
+
desc "Check every case's input and expected answer, through the adapter and the scorer (no API calls)"
|
|
9
|
+
|
|
10
|
+
def call(**)
|
|
11
|
+
all = discovery.all
|
|
12
|
+
failures = LLM::Evals::Verify.new(configuration).problems
|
|
13
|
+
if failures.empty?
|
|
14
|
+
status_box :success, "#{all.size} cases verified across #{discovery.datasets.join(", ")}"
|
|
15
|
+
return
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
failures.each { |name, problems| problems.each { status_box :error, "#{name}: #{it}", stream: err } }
|
|
19
|
+
raise Error, "#{failures.size} case(s) failed verification"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
# Every command the executable answers to. The help's title and
|
|
6
|
+
# description come from the suite's configuration, which the executable
|
|
7
|
+
# loads before this module is first referenced.
|
|
8
|
+
module CLI
|
|
9
|
+
extend Dry::CLI::Registry
|
|
10
|
+
|
|
11
|
+
# What users type, so what the help and shell completion are written for.
|
|
12
|
+
# exe/llm-evals exists only for a system where `evals` is taken.
|
|
13
|
+
PROGRAM = "evals"
|
|
14
|
+
|
|
15
|
+
Dry::CLI::Help.configure do
|
|
16
|
+
configuration = LLM::Evals.configuration
|
|
17
|
+
title configuration.name == Configuration::DEFAULT_NAME ? PROGRAM : "#{PROGRAM} (#{configuration.name})"
|
|
18
|
+
types = configuration.providers.uniq(&:request_type).map { "#{it.request_type} (#{it.request_type_description})" }
|
|
19
|
+
description "#{configuration.description} Request types: #{types.join("; ")}."
|
|
20
|
+
|
|
21
|
+
columns = IO.console&.winsize&.last || 80
|
|
22
|
+
width columns.clamp(60, 120)
|
|
23
|
+
exit_code_without_arguments 0
|
|
24
|
+
|
|
25
|
+
styles do
|
|
26
|
+
heading :bold, :cyan, case: :UPPERCASE
|
|
27
|
+
usage :yellow
|
|
28
|
+
example :yellow
|
|
29
|
+
option :green
|
|
30
|
+
example_comment :bright_black
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
register "run", Run
|
|
35
|
+
register "compare", Compare
|
|
36
|
+
register "render", Render
|
|
37
|
+
register "cases", Cases
|
|
38
|
+
register "verify", Verify
|
|
39
|
+
register "version", Version, aliases: ["-V", "--version"]
|
|
40
|
+
register "completion", Dry::CLI::Autocomplete::Command[self, program_name: PROGRAM]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
# What a {Model} returns from one call: the text, what it cost, and the
|
|
6
|
+
# provider's raw response for an adapter that needs more than the text.
|
|
7
|
+
Completion = Data.define(:text, :usage, :raw) do
|
|
8
|
+
def initialize(text:, usage: Usage.zero, raw: nil) = super(text: text.to_s, usage: Usage.from(usage), raw: raw)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module LLM
|
|
6
|
+
module Evals
|
|
7
|
+
# The suite's `.llm-evals.config.yml`: which models to compare, at which
|
|
8
|
+
# settings, and which kinds of request the suite can produce.
|
|
9
|
+
#
|
|
10
|
+
# project: my-evals # Braintrust project name
|
|
11
|
+
# request_types: [prompt] # required: what the adapter sends; a provider must match
|
|
12
|
+
# tracker: braintrust # optional: the one remote tracker; else the environment decides
|
|
13
|
+
# logfire: # optional: a tracker's own settings
|
|
14
|
+
# include_replies: true
|
|
15
|
+
# require: my_gem/evals # optional: a path (or list) to require, for a suite with no eval_helper.rb
|
|
16
|
+
# adapter: MyGem::Evals::Adapter # optional: constant names, instantiated with no arguments
|
|
17
|
+
# scorer: MyGem::Evals::Scorer
|
|
18
|
+
# defaults:
|
|
19
|
+
# effort: default
|
|
20
|
+
# temperature: 0.2
|
|
21
|
+
# max_tokens: 4096
|
|
22
|
+
# parallelism: 4
|
|
23
|
+
# results:
|
|
24
|
+
# autosave: true # completed runs are saved as JSON (default)
|
|
25
|
+
# destination: ./evals/results/
|
|
26
|
+
# temp_dir: ./tmp # temporary JSON used when autosave is off
|
|
27
|
+
# display: browser # none, tui or browser
|
|
28
|
+
# weighted_score: # optional: what matters most; see {WeightedScore}
|
|
29
|
+
# weights:
|
|
30
|
+
# score: 0.6
|
|
31
|
+
# cost: -0.4
|
|
32
|
+
# vendors: # the OpenRouter list; every entry takes a prompt
|
|
33
|
+
# anthropic:
|
|
34
|
+
# - model: claude-haiku-5.5
|
|
35
|
+
# effort: [default, low] # a list expands into one contender per value
|
|
36
|
+
# direct: # a vendor's own API, one request type each
|
|
37
|
+
# typesafe:
|
|
38
|
+
# - model: jev-latest
|
|
39
|
+
#
|
|
40
|
+
# Each entry becomes "<vendor>/<model>", the id a provider recognises. A
|
|
41
|
+
# key under `direct:` names a provider; its entries carry that provider.
|
|
42
|
+
class Config
|
|
43
|
+
# The request types a suite may declare. `prompt` is a chat completion:
|
|
44
|
+
# system and user text in, text out. `typesafe` is TypeSafe's typed
|
|
45
|
+
# question API, whose request shape the first such suite will define.
|
|
46
|
+
REQUEST_TYPES = %w[prompt typesafe].freeze
|
|
47
|
+
|
|
48
|
+
BUILTIN = {
|
|
49
|
+
"effort" => "default", "temperature" => 0.2, "max_tokens" => 4096, "parallelism" => 4
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
Results = Data.define(:autosave, :destination, :temp_dir, :display_mode)
|
|
53
|
+
RESULT_DISPLAYS = %w[none tui browser].freeze
|
|
54
|
+
|
|
55
|
+
attr_reader :path, :direct_providers
|
|
56
|
+
|
|
57
|
+
# @param path [String] read when it exists; an absent file is an empty lineup
|
|
58
|
+
# @param direct_providers [Array<String>] the provider names `direct:` may use
|
|
59
|
+
def initialize(path, direct_providers: Providers::Registry.default.direct_names)
|
|
60
|
+
@path = path
|
|
61
|
+
@direct_providers = direct_providers
|
|
62
|
+
@data = File.exist?(path) ? (YAML.safe_load_file(path) || {}) : {}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [String, nil] the Braintrust project name
|
|
66
|
+
def project = @data["project"]
|
|
67
|
+
|
|
68
|
+
# The remote tracker the file chooses; a list of more than one is an
|
|
69
|
+
# error, since exactly one remote tracker runs, or none.
|
|
70
|
+
#
|
|
71
|
+
# @return [String, nil]
|
|
72
|
+
# @raise [Error] on a list of several
|
|
73
|
+
def tracker
|
|
74
|
+
listed = Array(@data["tracker"]).map(&:to_s)
|
|
75
|
+
raise Error, "#{path}: tracker: names #{listed.join(", ")}; exactly one remote tracker runs, or none" if listed.size > 1
|
|
76
|
+
|
|
77
|
+
listed.first
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A tracker's own section of the file, e.g. `logfire: { include_replies: true }`.
|
|
81
|
+
#
|
|
82
|
+
# @param name [String]
|
|
83
|
+
# @return [Hash]
|
|
84
|
+
def tracker_options(name) = (@data[name.to_s].is_a?(Hash) ? @data[name.to_s] : {}).transform_keys(&:to_s)
|
|
85
|
+
|
|
86
|
+
# @return [Array<String>] the `require:` key, one path or a list
|
|
87
|
+
def requires = Array(@data["require"]).map(&:to_s)
|
|
88
|
+
|
|
89
|
+
# @return [String, nil] the `adapter:` constant name
|
|
90
|
+
def adapter_name = @data["adapter"]&.to_s
|
|
91
|
+
|
|
92
|
+
# @return [String, nil] the `scorer:` constant name
|
|
93
|
+
def scorer_name = @data["scorer"]&.to_s
|
|
94
|
+
|
|
95
|
+
# The kinds of request the suite's adapter produces. A provider whose
|
|
96
|
+
# request type is not listed cannot serve this suite, whatever model it
|
|
97
|
+
# names, so the list is required: without it every contender would be
|
|
98
|
+
# admitted on faith and fail at the first call.
|
|
99
|
+
#
|
|
100
|
+
# @return [Array<Symbol>]
|
|
101
|
+
# @raise [Error] when the key is missing, empty, or names an unknown type
|
|
102
|
+
def request_types
|
|
103
|
+
listed = Array(@data["request_types"]).map(&:to_s)
|
|
104
|
+
raise Error, "#{path}: request_types is required; list one or more of #{REQUEST_TYPES.join(", ")}" if listed.empty?
|
|
105
|
+
|
|
106
|
+
unknown = listed - REQUEST_TYPES
|
|
107
|
+
raise Error, "#{path}: unknown request_types #{unknown.join(", ")}; known: #{REQUEST_TYPES.join(", ")}" if unknown.any?
|
|
108
|
+
|
|
109
|
+
listed.map(&:to_sym)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [Hash{String => Object}] the built-in defaults under the file's own
|
|
113
|
+
def defaults = BUILTIN.merge(@data.fetch("defaults", nil) || {})
|
|
114
|
+
|
|
115
|
+
# Where completed runs are saved and how they are presented. Paths are
|
|
116
|
+
# rooted at the caller's working directory so the same config works from
|
|
117
|
+
# a project root or when invoked by a task runner.
|
|
118
|
+
#
|
|
119
|
+
# @return [Results]
|
|
120
|
+
# @raise [Error] when a setting has the wrong type or an unknown display
|
|
121
|
+
def results
|
|
122
|
+
section = @data.fetch("results", {})
|
|
123
|
+
raise Error, "#{path}: results must be a mapping" unless section.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
autosave = section.fetch("autosave", true)
|
|
126
|
+
unless [true, false].include?(autosave)
|
|
127
|
+
raise Error, "#{path}: results.autosave must be true or false"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
display = section.fetch("display", "tui").to_s
|
|
131
|
+
unless RESULT_DISPLAYS.include?(display)
|
|
132
|
+
raise Error, "#{path}: results.display must be one of #{RESULT_DISPLAYS.join(", ")}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
destination = section.fetch("destination", "./evals/results").to_s
|
|
136
|
+
temp_dir = section.fetch("temp_dir", "./tmp").to_s
|
|
137
|
+
Results.new(autosave:,
|
|
138
|
+
destination: File.expand_path(destination, Dir.pwd),
|
|
139
|
+
temp_dir: File.expand_path(temp_dir, Dir.pwd),
|
|
140
|
+
display_mode: display)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @return [Hash{String => Object}] `weighted_score.weights`; see {WeightedScore}
|
|
144
|
+
# @raise [Error] when either level is not a mapping
|
|
145
|
+
def score_weights
|
|
146
|
+
section = @data["weighted_score"]
|
|
147
|
+
return {} if section.nil?
|
|
148
|
+
|
|
149
|
+
weights = section["weights"] if section.is_a?(Hash)
|
|
150
|
+
raise Error, "#{path}: weighted_score needs a weights: mapping of column to number" unless weights.is_a?(Hash)
|
|
151
|
+
|
|
152
|
+
weights.transform_keys(&:to_s)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Every vendor/model/effort/temperature combination the file lists,
|
|
156
|
+
# `vendors:` first, then `direct:`.
|
|
157
|
+
#
|
|
158
|
+
# @param overrides [Hash{String => Object}] values from the command line; nil entries are ignored
|
|
159
|
+
# @return [Array<Contender>]
|
|
160
|
+
# @raise [Error] on a key under `direct:` that names no provider
|
|
161
|
+
def contenders(overrides = {})
|
|
162
|
+
vendors = (@data["vendors"] || {}).flat_map do |vendor, entries|
|
|
163
|
+
Array(entries).flat_map { expand("#{vendor}/#{it.fetch("model")}", it, overrides) }
|
|
164
|
+
end
|
|
165
|
+
direct = (@data["direct"] || {}).flat_map do |name, entries|
|
|
166
|
+
unless direct_providers.include?(name.to_s)
|
|
167
|
+
raise Error, "#{path}: unknown provider #{name.inspect} under direct:; known: #{direct_providers.join(", ")}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
Array(entries).flat_map { expand("#{name}/#{it.fetch("model")}", it, overrides, provider: name.to_sym) }
|
|
171
|
+
end
|
|
172
|
+
vendors + direct
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The models given on the command line, at the file's default settings.
|
|
176
|
+
#
|
|
177
|
+
# @param models [Array<String>] model ids
|
|
178
|
+
# @param overrides [Hash{String => Object}]
|
|
179
|
+
# @return [Array<Contender>]
|
|
180
|
+
def contenders_for(models, overrides = {})
|
|
181
|
+
models.flat_map { expand(it, {}, overrides) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
def expand(model, entry, overrides, provider: nil)
|
|
187
|
+
settings = defaults.merge(entry).merge(overrides.compact)
|
|
188
|
+
Array(settings["effort"]).product(Array(settings["temperature"])).map do |effort, temperature|
|
|
189
|
+
Contender.new(model: model,
|
|
190
|
+
effort: effort.to_s,
|
|
191
|
+
temperature: temperature,
|
|
192
|
+
max_tokens: Integer(settings["max_tokens"]),
|
|
193
|
+
provider: provider)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
# Everything a suite tells the gem, through {LLM::Evals.configure}.
|
|
6
|
+
# Paths are relative to `root`, the directory the eval helper lives in.
|
|
7
|
+
class Configuration
|
|
8
|
+
DEFAULT_GLOB = "datasets/*/cases/**/input.txt"
|
|
9
|
+
DEFAULT_NAME = "llm-evals"
|
|
10
|
+
DEFAULT_CONFIG = ".llm-evals.config.yml"
|
|
11
|
+
|
|
12
|
+
attr_writer :adapter, :scorer
|
|
13
|
+
attr_reader :name, :root, :env_files, :cases_glob, :hooks, :providers, :trackers, :description
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@name = DEFAULT_NAME
|
|
17
|
+
@root = Dir.pwd
|
|
18
|
+
@env_files = []
|
|
19
|
+
@cases_glob = DEFAULT_GLOB
|
|
20
|
+
@config_path = DEFAULT_CONFIG
|
|
21
|
+
@hooks = []
|
|
22
|
+
@providers = Providers::Registry.default
|
|
23
|
+
@trackers = Trackers::Registry.default
|
|
24
|
+
@description = "Run a suite of cases against one or more models and score the replies."
|
|
25
|
+
@project = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Names the suite. The name titles the help and tags the experiments.
|
|
29
|
+
#
|
|
30
|
+
# @param name [String]
|
|
31
|
+
# @param root [String] where .llm-evals.config.yml and datasets/ live; the helper's directory
|
|
32
|
+
# @param env_files [Array<String>] dotenv files to load, relative to root; the first that exists wins nothing,
|
|
33
|
+
# all that exist load, and an exported variable beats them all
|
|
34
|
+
# @param description [String, nil] one line for the help's header
|
|
35
|
+
# @return [void]
|
|
36
|
+
def suite(name, root:, env_files: [], description: nil)
|
|
37
|
+
@name = name
|
|
38
|
+
@root = File.expand_path(root)
|
|
39
|
+
@env_files = env_files.map { File.expand_path(it, @root) }
|
|
40
|
+
@description = description if description
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Where the cases are: a glob relative to root ending in the input file.
|
|
44
|
+
# Each match is a case; the expected answer is `expected.json` beside it.
|
|
45
|
+
#
|
|
46
|
+
# @param glob [String]
|
|
47
|
+
# @return [void]
|
|
48
|
+
def cases(glob)
|
|
49
|
+
@cases_glob = glob
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param object [Adapter]
|
|
53
|
+
# @return [void]
|
|
54
|
+
|
|
55
|
+
# @param object [Adapter, nil] setting; reading when nil
|
|
56
|
+
# @return [Adapter]
|
|
57
|
+
def adapter(object = nil)
|
|
58
|
+
@adapter = object unless object.nil?
|
|
59
|
+
@adapter
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param object [Scorer, nil] setting; reading when nil
|
|
63
|
+
# @return [Scorer]
|
|
64
|
+
def scorer(object = nil)
|
|
65
|
+
@scorer = object unless object.nil?
|
|
66
|
+
@scorer
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param object [Scorer]
|
|
70
|
+
# @return [void]
|
|
71
|
+
|
|
72
|
+
# Runs once, after the env files are loaded and before any model is
|
|
73
|
+
# called. The place to reset a library that read ENV at require time.
|
|
74
|
+
#
|
|
75
|
+
# @yield
|
|
76
|
+
# @return [void]
|
|
77
|
+
def before_run(&block)
|
|
78
|
+
@hooks << block
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Adds a provider, ahead of the defaults, so a suite's own transport
|
|
82
|
+
# wins the ids it claims.
|
|
83
|
+
#
|
|
84
|
+
# @param provider [Providers::Base]
|
|
85
|
+
# @return [void]
|
|
86
|
+
def provider(provider)
|
|
87
|
+
@providers.register(provider)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Adds a tracker class, so a run can choose it by name with `--tracker`
|
|
91
|
+
# or `tracker:`, or have its env key select it.
|
|
92
|
+
#
|
|
93
|
+
# @param klass [Class<Tracker>]
|
|
94
|
+
# @return [void]
|
|
95
|
+
def tracker(klass)
|
|
96
|
+
@trackers.register(klass)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# @param path [String, nil] setting, relative to root; reading when nil
|
|
100
|
+
# @return [String] the absolute path of the lineup file, `.llm-evals.config.yml` at the root by default
|
|
101
|
+
def config(path = nil)
|
|
102
|
+
@config_path = path unless path.nil?
|
|
103
|
+
File.expand_path(@config_path, root)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @param name [String, nil] setting; reading when nil
|
|
107
|
+
# @return [String, nil] the Braintrust project, when .llm-evals.config.yml does not name one
|
|
108
|
+
def project(name = nil)
|
|
109
|
+
@project = name unless name.nil?
|
|
110
|
+
@project
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @return [Discovery] the cases, as the glob finds them
|
|
114
|
+
def discovery = Discovery.new(root: root, glob: cases_glob)
|
|
115
|
+
|
|
116
|
+
# @return [Config] the lineup file
|
|
117
|
+
def lineup = Config.new(config, direct_providers: providers.direct_names)
|
|
118
|
+
|
|
119
|
+
# @return [Runtime]
|
|
120
|
+
def runtime = Runtime.new(env_files: env_files, hooks: hooks, app_name: name)
|
|
121
|
+
|
|
122
|
+
# @return [Boolean] whether an adapter and a scorer are registered
|
|
123
|
+
def complete? = !adapter.nil? && !scorer.nil?
|
|
124
|
+
|
|
125
|
+
# Fails a command that needs the suite when no helper was loaded, with
|
|
126
|
+
# the fix in the message, instead of a NoMethodError on nil.
|
|
127
|
+
#
|
|
128
|
+
# @return [self]
|
|
129
|
+
# @raise [Error]
|
|
130
|
+
def require_suite!
|
|
131
|
+
resolve_from_config!
|
|
132
|
+
return self if complete?
|
|
133
|
+
|
|
134
|
+
raise Error,
|
|
135
|
+
"no suite is configured: register an adapter and a scorer in evals/eval_helper.rb, or set " \
|
|
136
|
+
"adapter: and scorer: (with require:) in #{config}; --helper PATH or LLM_EVALS_HELPER names another helper"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Fills whatever the helper left unset from the config file's `require:`,
|
|
140
|
+
# `adapter:` and `scorer:` keys, so a suite can be config-only: its Ruby
|
|
141
|
+
# lives in its own gem and `evals/` holds the file and the datasets. The
|
|
142
|
+
# helper, when there is one, was loaded first and wins.
|
|
143
|
+
#
|
|
144
|
+
# @return [void]
|
|
145
|
+
# @raise [Error] naming the key and the value when a require fails or a constant is unknown
|
|
146
|
+
def resolve_from_config!
|
|
147
|
+
return if complete? || !File.exist?(config)
|
|
148
|
+
|
|
149
|
+
file = lineup
|
|
150
|
+
file.requires.each do |feature|
|
|
151
|
+
require feature
|
|
152
|
+
rescue LoadError => e
|
|
153
|
+
raise Error, "#{config}: require: #{feature} could not be loaded (#{e.message}); is its gem in the bundle?"
|
|
154
|
+
end
|
|
155
|
+
@adapter ||= instantiate("adapter", file.adapter_name)
|
|
156
|
+
@scorer ||= instantiate("scorer", file.scorer_name) # rubocop:disable Naming/MemoizedInstanceVariableName
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
# @return [Object, nil] a new instance of the named constant; nil when no name is set
|
|
162
|
+
def instantiate(key, name)
|
|
163
|
+
return nil if name.nil? || name.empty?
|
|
164
|
+
|
|
165
|
+
Object.const_get(name).new
|
|
166
|
+
rescue NameError, ArgumentError => e
|
|
167
|
+
raise Error,
|
|
168
|
+
"#{config}: #{key}: #{name} could not be instantiated (#{e.class}: #{e.message}); " \
|
|
169
|
+
"name a constant the require: key loads, taking no arguments"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
# One model at one setting: what a single experiment runs. Built by
|
|
6
|
+
# {Config} from the lineup file, or from `-m` on the command line.
|
|
7
|
+
#
|
|
8
|
+
# `provider` is nil for a `vendors:` entry, which OpenRouter serves, and
|
|
9
|
+
# the provider's name for a `direct:` entry. It travels with the
|
|
10
|
+
# contender rather than being read back out of the id, so `typesafe/jev-router`
|
|
11
|
+
# (Jev on OpenRouter) and a direct `typesafe/jev-latest` can never be confused.
|
|
12
|
+
Contender = Data.define(:model, :effort, :temperature, :max_tokens, :provider) do
|
|
13
|
+
def initialize(model:, effort: "default", temperature: "default", max_tokens: nil, provider: nil) = super
|
|
14
|
+
|
|
15
|
+
# @return [Boolean] whether the entry came from `direct:`
|
|
16
|
+
def direct? = !provider.nil?
|
|
17
|
+
|
|
18
|
+
# @return [String] unique across the lineup: "typesafe:typesafe/jev-latest" for a direct entry
|
|
19
|
+
def key = direct? ? "#{provider}:#{model}" : model
|
|
20
|
+
|
|
21
|
+
# The settings as a {Model} takes them: "default" means nil, send nothing.
|
|
22
|
+
#
|
|
23
|
+
# @return [Hash{Symbol => Object}]
|
|
24
|
+
def settings
|
|
25
|
+
{ effort: effort.to_s == "default" || effort.nil? ? nil : effort.to_s,
|
|
26
|
+
temperature: temperature.to_s == "default" || temperature.nil? ? nil : Float(temperature),
|
|
27
|
+
max_tokens: max_tokens.nil? ? nil : Integer(max_tokens) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|