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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +635 -0
  5. data/exe/agent-evals +9 -0
  6. data/lib/agent/evals/adapter.rb +47 -0
  7. data/lib/agent/evals/case.rb +36 -0
  8. data/lib/agent/evals/cli/cases.rb +48 -0
  9. data/lib/agent/evals/cli/command.rb +163 -0
  10. data/lib/agent/evals/cli/compare.rb +33 -0
  11. data/lib/agent/evals/cli/model_command.rb +393 -0
  12. data/lib/agent/evals/cli/progress_bars.rb +90 -0
  13. data/lib/agent/evals/cli/run.rb +61 -0
  14. data/lib/agent/evals/cli/verify.rb +24 -0
  15. data/lib/agent/evals/cli/version.rb +15 -0
  16. data/lib/agent/evals/cli.rb +40 -0
  17. data/lib/agent/evals/completion.rb +11 -0
  18. data/lib/agent/evals/config.rb +146 -0
  19. data/lib/agent/evals/configuration.rb +172 -0
  20. data/lib/agent/evals/contender.rb +31 -0
  21. data/lib/agent/evals/discovery.rb +95 -0
  22. data/lib/agent/evals/experiment.rb +133 -0
  23. data/lib/agent/evals/helper.rb +98 -0
  24. data/lib/agent/evals/launcher.rb +37 -0
  25. data/lib/agent/evals/metric.rb +26 -0
  26. data/lib/agent/evals/model.rb +55 -0
  27. data/lib/agent/evals/providers/base.rb +93 -0
  28. data/lib/agent/evals/providers/catalog.rb +32 -0
  29. data/lib/agent/evals/providers/open_router.rb +64 -0
  30. data/lib/agent/evals/providers/registry.rb +112 -0
  31. data/lib/agent/evals/providers/type_safe.rb +53 -0
  32. data/lib/agent/evals/reply.rb +18 -0
  33. data/lib/agent/evals/run_result.rb +14 -0
  34. data/lib/agent/evals/runtime.rb +74 -0
  35. data/lib/agent/evals/schema_validator.rb +84 -0
  36. data/lib/agent/evals/score.rb +26 -0
  37. data/lib/agent/evals/scorer.rb +45 -0
  38. data/lib/agent/evals/testing/echo_adapter.rb +29 -0
  39. data/lib/agent/evals/testing/fake_model.rb +18 -0
  40. data/lib/agent/evals/testing/fake_provider.rb +67 -0
  41. data/lib/agent/evals/tracker.rb +80 -0
  42. data/lib/agent/evals/trackers/braintrust.rb +91 -0
  43. data/lib/agent/evals/trackers/local.rb +78 -0
  44. data/lib/agent/evals/trackers/logfire.rb +143 -0
  45. data/lib/agent/evals/trackers/registry.rb +82 -0
  46. data/lib/agent/evals/usage.rb +33 -0
  47. data/lib/agent/evals/verify.rb +49 -0
  48. data/lib/agent/evals/version.rb +7 -0
  49. data/lib/agent/evals/warnings.rb +89 -0
  50. data/lib/agent/evals.rb +82 -0
  51. 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