agent-evals 0.1.1
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 +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +635 -0
- data/exe/agent-evals +9 -0
- data/lib/agent/evals/adapter.rb +47 -0
- data/lib/agent/evals/case.rb +36 -0
- data/lib/agent/evals/cli/cases.rb +48 -0
- data/lib/agent/evals/cli/command.rb +163 -0
- data/lib/agent/evals/cli/compare.rb +33 -0
- data/lib/agent/evals/cli/model_command.rb +393 -0
- data/lib/agent/evals/cli/progress_bars.rb +90 -0
- data/lib/agent/evals/cli/run.rb +61 -0
- data/lib/agent/evals/cli/verify.rb +24 -0
- data/lib/agent/evals/cli/version.rb +15 -0
- data/lib/agent/evals/cli.rb +40 -0
- data/lib/agent/evals/completion.rb +11 -0
- data/lib/agent/evals/config.rb +146 -0
- data/lib/agent/evals/configuration.rb +172 -0
- data/lib/agent/evals/contender.rb +31 -0
- data/lib/agent/evals/discovery.rb +95 -0
- data/lib/agent/evals/experiment.rb +133 -0
- data/lib/agent/evals/helper.rb +98 -0
- data/lib/agent/evals/launcher.rb +37 -0
- data/lib/agent/evals/metric.rb +26 -0
- data/lib/agent/evals/model.rb +55 -0
- data/lib/agent/evals/providers/base.rb +93 -0
- data/lib/agent/evals/providers/catalog.rb +32 -0
- data/lib/agent/evals/providers/open_router.rb +64 -0
- data/lib/agent/evals/providers/registry.rb +112 -0
- data/lib/agent/evals/providers/type_safe.rb +53 -0
- data/lib/agent/evals/reply.rb +18 -0
- data/lib/agent/evals/run_result.rb +14 -0
- data/lib/agent/evals/runtime.rb +74 -0
- data/lib/agent/evals/schema_validator.rb +84 -0
- data/lib/agent/evals/score.rb +26 -0
- data/lib/agent/evals/scorer.rb +45 -0
- data/lib/agent/evals/testing/echo_adapter.rb +29 -0
- data/lib/agent/evals/testing/fake_model.rb +18 -0
- data/lib/agent/evals/testing/fake_provider.rb +67 -0
- data/lib/agent/evals/tracker.rb +80 -0
- data/lib/agent/evals/trackers/braintrust.rb +91 -0
- data/lib/agent/evals/trackers/local.rb +78 -0
- data/lib/agent/evals/trackers/logfire.rb +143 -0
- data/lib/agent/evals/trackers/registry.rb +82 -0
- data/lib/agent/evals/usage.rb +33 -0
- data/lib/agent/evals/verify.rb +49 -0
- data/lib/agent/evals/version.rb +7 -0
- data/lib/agent/evals/warnings.rb +89 -0
- data/lib/agent/evals.rb +82 -0
- metadata +257 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module Evals
|
|
7
|
+
# The suite's `.agent-eval.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
|
+
# vendors: # the OpenRouter list; every entry takes a prompt
|
|
24
|
+
# anthropic:
|
|
25
|
+
# - model: claude-haiku-5.5
|
|
26
|
+
# effort: [default, low] # a list expands into one contender per value
|
|
27
|
+
# direct: # a vendor's own API, one request type each
|
|
28
|
+
# typesafe:
|
|
29
|
+
# - model: jev-latest
|
|
30
|
+
#
|
|
31
|
+
# Each entry becomes "<vendor>/<model>", the id a provider recognises. A
|
|
32
|
+
# key under `direct:` names a provider; its entries carry that provider.
|
|
33
|
+
class Config
|
|
34
|
+
# The request types a suite may declare. `prompt` is a chat completion:
|
|
35
|
+
# system and user text in, text out. `typesafe` is TypeSafe's typed
|
|
36
|
+
# question API, whose request shape the first such suite will define.
|
|
37
|
+
REQUEST_TYPES = %w[prompt typesafe].freeze
|
|
38
|
+
|
|
39
|
+
BUILTIN = {
|
|
40
|
+
"effort" => "default", "temperature" => 0.2, "max_tokens" => 4096, "parallelism" => 4
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
attr_reader :path, :direct_providers
|
|
44
|
+
|
|
45
|
+
# @param path [String] read when it exists; an absent file is an empty lineup
|
|
46
|
+
# @param direct_providers [Array<String>] the provider names `direct:` may use
|
|
47
|
+
def initialize(path, direct_providers: Providers::Registry.default.direct_names)
|
|
48
|
+
@path = path
|
|
49
|
+
@direct_providers = direct_providers
|
|
50
|
+
@data = File.exist?(path) ? (YAML.safe_load_file(path) || {}) : {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [String, nil] the Braintrust project name
|
|
54
|
+
def project = @data["project"]
|
|
55
|
+
|
|
56
|
+
# The remote tracker the file chooses; a list of more than one is an
|
|
57
|
+
# error, since exactly one remote tracker runs, or none.
|
|
58
|
+
#
|
|
59
|
+
# @return [String, nil]
|
|
60
|
+
# @raise [Error] on a list of several
|
|
61
|
+
def tracker
|
|
62
|
+
listed = Array(@data["tracker"]).map(&:to_s)
|
|
63
|
+
raise Error, "#{path}: tracker: names #{listed.join(", ")}; exactly one remote tracker runs, or none" if listed.size > 1
|
|
64
|
+
|
|
65
|
+
listed.first
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A tracker's own section of the file, e.g. `logfire: { include_replies: true }`.
|
|
69
|
+
#
|
|
70
|
+
# @param name [String]
|
|
71
|
+
# @return [Hash]
|
|
72
|
+
def tracker_options(name) = (@data[name.to_s].is_a?(Hash) ? @data[name.to_s] : {}).transform_keys(&:to_s)
|
|
73
|
+
|
|
74
|
+
# @return [Array<String>] the `require:` key, one path or a list
|
|
75
|
+
def requires = Array(@data["require"]).map(&:to_s)
|
|
76
|
+
|
|
77
|
+
# @return [String, nil] the `adapter:` constant name
|
|
78
|
+
def adapter_name = @data["adapter"]&.to_s
|
|
79
|
+
|
|
80
|
+
# @return [String, nil] the `scorer:` constant name
|
|
81
|
+
def scorer_name = @data["scorer"]&.to_s
|
|
82
|
+
|
|
83
|
+
# The kinds of request the suite's adapter produces. A provider whose
|
|
84
|
+
# request type is not listed cannot serve this suite, whatever model it
|
|
85
|
+
# names, so the list is required: without it every contender would be
|
|
86
|
+
# admitted on faith and fail at the first call.
|
|
87
|
+
#
|
|
88
|
+
# @return [Array<Symbol>]
|
|
89
|
+
# @raise [Error] when the key is missing, empty, or names an unknown type
|
|
90
|
+
def request_types
|
|
91
|
+
listed = Array(@data["request_types"]).map(&:to_s)
|
|
92
|
+
raise Error, "#{path}: request_types is required; list one or more of #{REQUEST_TYPES.join(", ")}" if listed.empty?
|
|
93
|
+
|
|
94
|
+
unknown = listed - REQUEST_TYPES
|
|
95
|
+
raise Error, "#{path}: unknown request_types #{unknown.join(", ")}; known: #{REQUEST_TYPES.join(", ")}" if unknown.any?
|
|
96
|
+
|
|
97
|
+
listed.map(&:to_sym)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [Hash{String => Object}] the built-in defaults under the file's own
|
|
101
|
+
def defaults = BUILTIN.merge(@data.fetch("defaults", nil) || {})
|
|
102
|
+
|
|
103
|
+
# Every vendor/model/effort/temperature combination the file lists,
|
|
104
|
+
# `vendors:` first, then `direct:`.
|
|
105
|
+
#
|
|
106
|
+
# @param overrides [Hash{String => Object}] values from the command line; nil entries are ignored
|
|
107
|
+
# @return [Array<Contender>]
|
|
108
|
+
# @raise [Error] on a key under `direct:` that names no provider
|
|
109
|
+
def contenders(overrides = {})
|
|
110
|
+
vendors = (@data["vendors"] || {}).flat_map do |vendor, entries|
|
|
111
|
+
Array(entries).flat_map { expand("#{vendor}/#{it.fetch("model")}", it, overrides) }
|
|
112
|
+
end
|
|
113
|
+
direct = (@data["direct"] || {}).flat_map do |name, entries|
|
|
114
|
+
unless direct_providers.include?(name.to_s)
|
|
115
|
+
raise Error, "#{path}: unknown provider #{name.inspect} under direct:; known: #{direct_providers.join(", ")}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
Array(entries).flat_map { expand("#{name}/#{it.fetch("model")}", it, overrides, provider: name.to_sym) }
|
|
119
|
+
end
|
|
120
|
+
vendors + direct
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The models given on the command line, at the file's default settings.
|
|
124
|
+
#
|
|
125
|
+
# @param models [Array<String>] model ids
|
|
126
|
+
# @param overrides [Hash{String => Object}]
|
|
127
|
+
# @return [Array<Contender>]
|
|
128
|
+
def contenders_for(models, overrides = {})
|
|
129
|
+
models.flat_map { expand(it, {}, overrides) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def expand(model, entry, overrides, provider: nil)
|
|
135
|
+
settings = defaults.merge(entry).merge(overrides.compact)
|
|
136
|
+
Array(settings["effort"]).product(Array(settings["temperature"])).map do |effort, temperature|
|
|
137
|
+
Contender.new(model: model,
|
|
138
|
+
effort: effort.to_s,
|
|
139
|
+
temperature: temperature,
|
|
140
|
+
max_tokens: Integer(settings["max_tokens"]),
|
|
141
|
+
provider: provider)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
# Everything a suite tells the gem, through {Agent::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_CONFIG = ".agent-eval.config.yml"
|
|
10
|
+
|
|
11
|
+
attr_writer :adapter, :scorer
|
|
12
|
+
attr_reader :name, :root, :env_files, :cases_glob, :hooks, :providers, :trackers, :description
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@name = "agent-evals"
|
|
16
|
+
@root = Dir.pwd
|
|
17
|
+
@env_files = []
|
|
18
|
+
@cases_glob = DEFAULT_GLOB
|
|
19
|
+
@config_path = DEFAULT_CONFIG
|
|
20
|
+
@hooks = []
|
|
21
|
+
@providers = Providers::Registry.default
|
|
22
|
+
@trackers = Trackers::Registry.default
|
|
23
|
+
@description = "Run a suite of cases against one or more models and score the replies."
|
|
24
|
+
@project = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Names the suite. The name titles the help and tags the experiments.
|
|
28
|
+
#
|
|
29
|
+
# @param name [String]
|
|
30
|
+
# @param root [String] where .agent-eval.config.yml and datasets/ live; the helper's directory
|
|
31
|
+
# @param env_files [Array<String>] dotenv files to load, relative to root; the first that exists wins nothing,
|
|
32
|
+
# all that exist load, and an exported variable beats them all
|
|
33
|
+
# @param description [String, nil] one line for the help's header
|
|
34
|
+
# @return [void]
|
|
35
|
+
def suite(name, root:, env_files: [], description: nil)
|
|
36
|
+
@name = name
|
|
37
|
+
@root = File.expand_path(root)
|
|
38
|
+
@env_files = env_files.map { File.expand_path(it, @root) }
|
|
39
|
+
@description = description if description
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Where the cases are: a glob relative to root ending in the input file.
|
|
43
|
+
# Each match is a case; the expected answer is `expected.json` beside it.
|
|
44
|
+
#
|
|
45
|
+
# @param glob [String]
|
|
46
|
+
# @return [void]
|
|
47
|
+
def cases(glob)
|
|
48
|
+
@cases_glob = glob
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param object [Adapter]
|
|
52
|
+
# @return [void]
|
|
53
|
+
|
|
54
|
+
# @param object [Adapter, nil] setting; reading when nil
|
|
55
|
+
# @return [Adapter]
|
|
56
|
+
def adapter(object = nil)
|
|
57
|
+
@adapter = object unless object.nil?
|
|
58
|
+
@adapter
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param object [Scorer, nil] setting; reading when nil
|
|
62
|
+
# @return [Scorer]
|
|
63
|
+
def scorer(object = nil)
|
|
64
|
+
@scorer = object unless object.nil?
|
|
65
|
+
@scorer
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @param object [Scorer]
|
|
69
|
+
# @return [void]
|
|
70
|
+
|
|
71
|
+
# Runs once, after the env files are loaded and before any model is
|
|
72
|
+
# called. The place to reset a library that read ENV at require time.
|
|
73
|
+
#
|
|
74
|
+
# @yield
|
|
75
|
+
# @return [void]
|
|
76
|
+
def before_run(&block)
|
|
77
|
+
@hooks << block
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Adds a provider, ahead of the defaults, so a suite's own transport
|
|
81
|
+
# wins the ids it claims.
|
|
82
|
+
#
|
|
83
|
+
# @param provider [Providers::Base]
|
|
84
|
+
# @return [void]
|
|
85
|
+
def provider(provider)
|
|
86
|
+
@providers.register(provider)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Adds a tracker class, so a run can choose it by name with `--tracker`
|
|
90
|
+
# or `tracker:`, or have its env key select it.
|
|
91
|
+
#
|
|
92
|
+
# @param klass [Class<Tracker>]
|
|
93
|
+
# @return [void]
|
|
94
|
+
def tracker(klass)
|
|
95
|
+
@trackers.register(klass)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @param path [String, nil] setting, relative to root; reading when nil
|
|
99
|
+
# @return [String] the absolute path of the lineup file, `.agent-eval.config.yml` at the root by default
|
|
100
|
+
def config(path = nil)
|
|
101
|
+
@config_path = path unless path.nil?
|
|
102
|
+
File.expand_path(@config_path, root)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @param name [String, nil] setting; reading when nil
|
|
106
|
+
# @return [String, nil] the Braintrust project, when .agent-eval.config.yml does not name one
|
|
107
|
+
def project(name = nil)
|
|
108
|
+
@project = name unless name.nil?
|
|
109
|
+
@project
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return [Discovery] the cases, as the glob finds them
|
|
113
|
+
def discovery = Discovery.new(root: root, glob: cases_glob)
|
|
114
|
+
|
|
115
|
+
# @return [Config] the lineup file
|
|
116
|
+
def lineup = Config.new(config, direct_providers: providers.direct_names)
|
|
117
|
+
|
|
118
|
+
# @return [Runtime]
|
|
119
|
+
def runtime = Runtime.new(env_files: env_files, hooks: hooks, app_name: name)
|
|
120
|
+
|
|
121
|
+
# @return [Boolean] whether an adapter and a scorer are registered
|
|
122
|
+
def complete? = !adapter.nil? && !scorer.nil?
|
|
123
|
+
|
|
124
|
+
# Fails a command that needs the suite when no helper was loaded, with
|
|
125
|
+
# the fix in the message, instead of a NoMethodError on nil.
|
|
126
|
+
#
|
|
127
|
+
# @return [self]
|
|
128
|
+
# @raise [Error]
|
|
129
|
+
def require_suite!
|
|
130
|
+
resolve_from_config!
|
|
131
|
+
return self if complete?
|
|
132
|
+
|
|
133
|
+
raise Error,
|
|
134
|
+
"no suite is configured: register an adapter and a scorer in evals/eval_helper.rb, or set " \
|
|
135
|
+
"adapter: and scorer: (with require:) in #{config}; --helper PATH or AGENT_EVALS_HELPER names another helper"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Fills whatever the helper left unset from the config file's `require:`,
|
|
139
|
+
# `adapter:` and `scorer:` keys, so a suite can be config-only: its Ruby
|
|
140
|
+
# lives in its own gem and `evals/` holds the file and the datasets. The
|
|
141
|
+
# helper, when there is one, was loaded first and wins.
|
|
142
|
+
#
|
|
143
|
+
# @return [void]
|
|
144
|
+
# @raise [Error] naming the key and the value when a require fails or a constant is unknown
|
|
145
|
+
def resolve_from_config!
|
|
146
|
+
return if complete? || !File.exist?(config)
|
|
147
|
+
|
|
148
|
+
file = lineup
|
|
149
|
+
file.requires.each do |feature|
|
|
150
|
+
require feature
|
|
151
|
+
rescue LoadError => e
|
|
152
|
+
raise Error, "#{config}: require: #{feature} could not be loaded (#{e.message}); is its gem in the bundle?"
|
|
153
|
+
end
|
|
154
|
+
@adapter ||= instantiate("adapter", file.adapter_name)
|
|
155
|
+
@scorer ||= instantiate("scorer", file.scorer_name) # rubocop:disable Naming/MemoizedInstanceVariableName
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
# @return [Object, nil] a new instance of the named constant; nil when no name is set
|
|
161
|
+
def instantiate(key, name)
|
|
162
|
+
return nil if name.nil? || name.empty?
|
|
163
|
+
|
|
164
|
+
Object.const_get(name).new
|
|
165
|
+
rescue NameError, ArgumentError => e
|
|
166
|
+
raise Error,
|
|
167
|
+
"#{config}: #{key}: #{name} could not be instantiated (#{e.class}: #{e.message}); " \
|
|
168
|
+
"name a constant the require: key loads, taking no arguments"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
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
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
# Finds the cases under a root with a glob and decodes each path into a
|
|
6
|
+
# {Case}. The default layout is
|
|
7
|
+
#
|
|
8
|
+
# datasets/<dataset>/cases/<variant>/[NNN/]input.txt
|
|
9
|
+
# datasets/<dataset>/cases/<variant>/[NNN/]expected.json
|
|
10
|
+
#
|
|
11
|
+
# The folder after `datasets/` is the dataset, the folder holding the
|
|
12
|
+
# input is the variant, unless it is a number, in which case it is the
|
|
13
|
+
# case number and its parent is the variant.
|
|
14
|
+
class Discovery
|
|
15
|
+
EXPECTED = "expected.json"
|
|
16
|
+
METADATA = "metadata.json"
|
|
17
|
+
|
|
18
|
+
attr_reader :root, :glob
|
|
19
|
+
|
|
20
|
+
# @param root [String] an absolute directory
|
|
21
|
+
# @param glob [String] relative to root
|
|
22
|
+
def initialize(root:, glob: Configuration::DEFAULT_GLOB)
|
|
23
|
+
@root = root
|
|
24
|
+
@glob = glob
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Array<String>] every dataset that has at least one case, sorted
|
|
28
|
+
def datasets = all.map(&:dataset).uniq.sort
|
|
29
|
+
|
|
30
|
+
# @param dataset [String, nil] one dataset; nil for all of them
|
|
31
|
+
# @param only [Array<String>] id prefixes to keep ("clear", "clear/00"); empty keeps all
|
|
32
|
+
# @return [Array<Case>] sorted by dataset, variant and number
|
|
33
|
+
# @raise [Error] when the dataset does not exist
|
|
34
|
+
def cases(dataset: nil, only: [])
|
|
35
|
+
found = all
|
|
36
|
+
if dataset
|
|
37
|
+
raise Error, "no dataset #{dataset.inspect}; have #{datasets.join(", ")}" unless datasets.include?(dataset)
|
|
38
|
+
|
|
39
|
+
found = found.select { it.dataset == dataset }
|
|
40
|
+
end
|
|
41
|
+
only = only.flat_map { it.split(",") }.map(&:strip).reject(&:empty?)
|
|
42
|
+
found = found.select { |kase| only.any? { kase.id.start_with?(it) || kase.key.start_with?(it) } } unless only.empty?
|
|
43
|
+
found
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Array<Case>]
|
|
47
|
+
def all
|
|
48
|
+
@all ||= Dir[File.join(root, glob)].map { decode(it) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Builds the case at one input file.
|
|
52
|
+
#
|
|
53
|
+
# @param input_path [String]
|
|
54
|
+
# @return [Case]
|
|
55
|
+
def decode(input_path)
|
|
56
|
+
dir = File.dirname(input_path)
|
|
57
|
+
parts = Pathname(dir).relative_path_from(Pathname(root)).each_filename.to_a
|
|
58
|
+
number = parts.last.match?(/\A\d+\z/) ? parts.last : nil
|
|
59
|
+
variant = number ? parts[-2] : parts.last
|
|
60
|
+
expected, problem = read_expected(File.join(dir, EXPECTED))
|
|
61
|
+
Case.new(
|
|
62
|
+
id: [variant, number].compact.join("/"),
|
|
63
|
+
dataset: dataset_of(parts),
|
|
64
|
+
variant: variant,
|
|
65
|
+
input: File.read(input_path).strip,
|
|
66
|
+
expected: expected,
|
|
67
|
+
path: dir,
|
|
68
|
+
metadata: metadata(dir, number, problem)
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# The folder after `datasets/`, else the first folder, so a glob that
|
|
75
|
+
# does not say `datasets` still groups by its top level.
|
|
76
|
+
def dataset_of(parts)
|
|
77
|
+
index = parts.index("datasets")
|
|
78
|
+
index && parts[index + 1] ? parts[index + 1] : parts.first
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def read_expected(path)
|
|
82
|
+
return [nil, "#{EXPECTED} is missing"] unless File.exist?(path)
|
|
83
|
+
|
|
84
|
+
[JSON.parse(File.read(path)), nil]
|
|
85
|
+
rescue JSON::ParserError => e
|
|
86
|
+
[nil, "#{EXPECTED} is not valid JSON: #{e.message.lines.first&.strip}"]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def metadata(dir, number, problem)
|
|
90
|
+
extra = File.exist?(File.join(dir, METADATA)) ? JSON.parse(File.read(File.join(dir, METADATA))).transform_keys(&:to_sym) : {}
|
|
91
|
+
{ number: number&.to_i, difficulty: number.to_i, expected_error: problem }.compact.merge(extra)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
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
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :model, :adapter, :scorer, :cases, :dataset, :parallelism, :trackers
|
|
25
|
+
|
|
26
|
+
# @param model [Model]
|
|
27
|
+
# @param cases [Array<Case>] all of one dataset
|
|
28
|
+
# @param adapter [Adapter]
|
|
29
|
+
# @param scorer [Scorer]
|
|
30
|
+
# @param parallelism [Integer] cases in flight at once
|
|
31
|
+
# @param trackers [Array<Tracker>] told about the run, in this order
|
|
32
|
+
def initialize(model:, cases:, adapter:, scorer:, parallelism: 4, trackers: [])
|
|
33
|
+
raise ArgumentError, "an experiment needs at least one case" if cases.empty?
|
|
34
|
+
|
|
35
|
+
@model = model
|
|
36
|
+
@cases = cases.sort_by { [it.variant, it.number || 0, it.id] }
|
|
37
|
+
@dataset = cases.first.dataset
|
|
38
|
+
@adapter = adapter
|
|
39
|
+
@scorer = scorer
|
|
40
|
+
@parallelism = [parallelism.to_i, 1].max
|
|
41
|
+
@trackers = trackers.select(&:enabled?)
|
|
42
|
+
@results = {}
|
|
43
|
+
@lock = Mutex.new
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [String] e.g. "anthropic/claude-haiku-5.5 effort=low t=0.0 [clear]"
|
|
47
|
+
def name = "#{model} [#{dataset}]"
|
|
48
|
+
|
|
49
|
+
# @param on_case [#call, nil] called once per finished case
|
|
50
|
+
# @return [Summary]
|
|
51
|
+
def run(on_case: nil)
|
|
52
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
53
|
+
adapter.prepare(dataset)
|
|
54
|
+
trackers.each { it.start(self) }
|
|
55
|
+
errors = run_locally(on_case)
|
|
56
|
+
summary = summarize(errors, nil, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
|
|
57
|
+
urls = trackers.map { it.finish(self, summary)&.fetch(:url, nil) }
|
|
58
|
+
summary.with(permalink: urls.compact.first)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Runs one case: the adapter, then the scorer. A reply the model failed
|
|
62
|
+
# (`Reply#error`) scores 0 without troubling the scorer; an exception is
|
|
63
|
+
# the transport's and is left to the caller, which counts it as an error.
|
|
64
|
+
#
|
|
65
|
+
# @param kase [Case]
|
|
66
|
+
# @return [RunResult]
|
|
67
|
+
def evaluate(kase)
|
|
68
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
69
|
+
reply = adapter.call(kase, model: model)
|
|
70
|
+
raise Error, "#{adapter.class}#call returned #{reply.class}, not an Agent::Evals::Reply" unless reply.is_a?(Reply)
|
|
71
|
+
|
|
72
|
+
score = reply.error? ? Score.failed(scorer.metrics, reply.error) : scorer.call(kase, reply, context: adapter.context(kase))
|
|
73
|
+
RunResult.new(experiment: self,
|
|
74
|
+
case: kase,
|
|
75
|
+
reply: reply,
|
|
76
|
+
score: score,
|
|
77
|
+
duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Remembers a result and tells the trackers, under one lock, so a
|
|
83
|
+
# tracker sees results one at a time and in the order they are kept.
|
|
84
|
+
def remember(result)
|
|
85
|
+
@lock.synchronize do
|
|
86
|
+
@results[result.case.key] = result
|
|
87
|
+
trackers.each { it.record(result) }
|
|
88
|
+
end
|
|
89
|
+
result
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A fixed pool of threads over a queue: `parallelism` cases in flight.
|
|
93
|
+
def run_locally(on_case)
|
|
94
|
+
queue = Queue.new
|
|
95
|
+
cases.each { queue << it }
|
|
96
|
+
queue.close
|
|
97
|
+
errors = Queue.new
|
|
98
|
+
Array.new([parallelism, cases.size].min) do
|
|
99
|
+
Thread.new do
|
|
100
|
+
while (kase = queue.pop)
|
|
101
|
+
begin
|
|
102
|
+
remember evaluate(kase)
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
remember RunResult.new(experiment: self, case: kase, error: "#{e.class}: #{e.message}")
|
|
105
|
+
errors << "#{kase.key}: #{e.class}: #{e.message}"
|
|
106
|
+
end
|
|
107
|
+
on_case&.call
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end.each(&:join)
|
|
111
|
+
Array.new(errors.size) { errors.pop }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def summarize(errors, permalink, duration)
|
|
115
|
+
results = @lock.synchronize { @results.values }.sort_by { cases.index(it.case) }
|
|
116
|
+
scored = results.filter_map(&:score)
|
|
117
|
+
means = scorer.metrics.to_h { |metric| [metric.name, metric.combine(scored.map { it.metrics[metric.name] })] }
|
|
118
|
+
means["score"] = scored.empty? ? nil : scored.sum(&:value).fdiv(scored.size)
|
|
119
|
+
Summary.new(
|
|
120
|
+
model: model,
|
|
121
|
+
dataset: dataset,
|
|
122
|
+
name: name,
|
|
123
|
+
means: means,
|
|
124
|
+
results: results,
|
|
125
|
+
errors: Array(errors),
|
|
126
|
+
duration: duration,
|
|
127
|
+
permalink: permalink,
|
|
128
|
+
usage: results.filter_map(&:reply).map(&:usage).reduce(Usage.zero, :+)
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module Evals
|
|
5
|
+
# Finds and loads the suite: `evals/eval_helper.rb`, the file that calls
|
|
6
|
+
# {Agent::Evals.configure}, or, for a config-only suite, the directory
|
|
7
|
+
# holding `evals/.agent-eval.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 = "AGENT_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/.agent-eval.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: Agent::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
|