activeagent 1.9.6 → 1.9.8

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +612 -28
  3. data/README.md +9 -0
  4. data/lib/active_agent/base.rb +1 -0
  5. data/lib/active_agent/cli/eval_commands.rb +50 -7
  6. data/lib/active_agent/concerns/tooling.rb +11 -0
  7. data/lib/active_agent/evals/capture.rb +286 -0
  8. data/lib/active_agent/evals/catalog.rb +150 -19
  9. data/lib/active_agent/evals/gate_check.rb +79 -0
  10. data/lib/active_agent/evals/pass_thresholds.rb +137 -0
  11. data/lib/active_agent/evals/patterns.rb +80 -0
  12. data/lib/active_agent/evals/publisher.rb +124 -8
  13. data/lib/active_agent/evals/report.rb +165 -20
  14. data/lib/active_agent/evals/report_html.rb +91 -17
  15. data/lib/active_agent/evals/report_markdown.rb +63 -6
  16. data/lib/active_agent/evals/result.rb +10 -2
  17. data/lib/active_agent/evals/rspec.rb +87 -0
  18. data/lib/active_agent/evals/ruby_llm.rb +105 -3
  19. data/lib/active_agent/evals/runner.rb +59 -16
  20. data/lib/active_agent/evals/scenario.rb +12 -4
  21. data/lib/active_agent/evals/scenario_parser.rb +9 -1
  22. data/lib/active_agent/evals/scorer.rb +53 -23
  23. data/lib/active_agent/evals/suite.rb +117 -6
  24. data/lib/active_agent/evals/test_helper.rb +286 -0
  25. data/lib/active_agent/evals.rb +9 -1
  26. data/lib/active_agent/model_capabilities.rb +1 -1
  27. data/lib/active_agent/providers/_base_provider.rb +18 -2
  28. data/lib/active_agent/providers/anthropic_provider.rb +10 -0
  29. data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
  30. data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
  31. data/lib/active_agent/providers/bedrock/bearer_client.rb +2 -1
  32. data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
  33. data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
  34. data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
  35. data/lib/active_agent/providers/common/usage.rb +6 -1
  36. data/lib/active_agent/providers/ollama/_types.rb +1 -0
  37. data/lib/active_agent/providers/ollama/catalog.rb +221 -0
  38. data/lib/active_agent/providers/ollama/cloud.rb +109 -0
  39. data/lib/active_agent/providers/ollama/options.rb +66 -2
  40. data/lib/active_agent/providers/ollama_provider.rb +55 -3
  41. data/lib/active_agent/providers/open_ai/responses_provider.rb +7 -0
  42. data/lib/active_agent/providers/requesty_provider.rb +8 -0
  43. data/lib/active_agent/providers/ruby_llm/tool_proxy.rb +15 -0
  44. data/lib/active_agent/providers/ruby_llm_provider.rb +41 -5
  45. data/lib/active_agent/providers/runcrate/_types.rb +17 -0
  46. data/lib/active_agent/providers/runcrate/options.rb +39 -0
  47. data/lib/active_agent/providers/runcrate_provider.rb +72 -0
  48. data/lib/active_agent/railtie.rb +4 -0
  49. data/lib/active_agent/version.rb +1 -1
  50. data/lib/tasks/active_agent/evals.rake +28 -0
  51. metadata +27 -5
  52. data/lib/tasks/activeagent_tasks.rake +0 -4
@@ -6,8 +6,9 @@ module ActiveAgent
6
6
  module Evals
7
7
  # A catalog of evaluation scenarios: the products an app evaluates, each
8
8
  # with the named sets of scenarios it is tested with. One YAML document,
9
- # or several layered like Suite's: a later document's product, set or
10
- # scenario with a key already present replaces it, a new key is appended.
9
+ # or several layered like Suite's: a later document's product, set,
10
+ # scenario or criterion with a key already present replaces it, a new key
11
+ # is appended.
11
12
  #
12
13
  # catalog: support_desk
13
14
  # name: Support Desk
@@ -16,11 +17,14 @@ module ActiveAgent
16
17
  # - key: triage
17
18
  # name: Triage agent
18
19
  # agent: TriageAgent # the agent class, or a dashboard agent's name
20
+ # criteria: { shared: [] } # optional; named criteria lists, as a Suite's
21
+ # pass: { min_pass_rate: 0.8 } # optional; the product's bars (PassThresholds)
19
22
  # sets:
20
23
  # - key: smoke
21
24
  # name: Smoke
22
25
  # judge: { kind: rules } # optional; an evaluation's judge_kind and judge_model
23
26
  # criteria: [] # optional; an evaluation's criteria
27
+ # pass: { gated: false } # optional; the set's own bars
24
28
  # scenarios:
25
29
  # - key: refund_request
26
30
  # prompt: A customer asks for a refund on order 1042.
@@ -47,12 +51,12 @@ module ActiveAgent
47
51
 
48
52
  # The keys a product, set or scenario may carry. Anything else is kept
49
53
  # under `metadata` so a document round-trips.
50
- PRODUCT_KEYS = %w[key name description agent sets].freeze
51
- SET_KEYS = %w[key name description judge criteria scenarios].freeze
52
- SCENARIO_KEYS = %w[key prompt expect expectations tools contains not_contains notes tags params production_only position].freeze
54
+ PRODUCT_KEYS = %w[key name description agent criteria pass sets].freeze
55
+ SET_KEYS = %w[key name description judge criteria pass scenarios].freeze
56
+ SCENARIO_KEYS = Scenario::DOCUMENT_KEYS
53
57
  CATALOG_KEYS = %w[catalog name description products suite groups].freeze
54
58
 
55
- Product = Struct.new(:key, :name, :description, :agent, :metadata, :sets, :position, keyword_init: true) do
59
+ Product = Struct.new(:key, :name, :description, :agent, :criteria, :pass, :metadata, :sets, :position, keyword_init: true) do
56
60
  def set(key)
57
61
  sets.find { |set| set.key == key.to_s }
58
62
  end
@@ -62,7 +66,7 @@ module ActiveAgent
62
66
  end
63
67
  end
64
68
 
65
- Set = Struct.new(:key, :name, :description, :judge, :criteria, :metadata, :scenarios, :position, keyword_init: true)
69
+ Set = Struct.new(:key, :name, :description, :judge, :criteria, :pass, :metadata, :scenarios, :position, keyword_init: true)
66
70
 
67
71
  attr_reader :key, :name, :description, :metadata, :products
68
72
 
@@ -114,14 +118,21 @@ module ActiveAgent
114
118
  # Scenarios across the catalog, narrowed by product key, set key,
115
119
  # scenario keys, or any of them; `production_only` scenarios are
116
120
  # dropped unless `include_production_only` is true. Each scenario's
117
- # group is its set's key and its group_name the set's name.
118
- def scenarios(product: nil, set: nil, keys: nil, include_production_only: true)
121
+ # group is its set's key and its group_name the set's name. With
122
+ # `strict`, every key in `keys` must name a scenario of the catalog (see
123
+ # Suite.select), and a key outside the chosen product or set selects
124
+ # nothing.
125
+ def scenarios(product: nil, set: nil, keys: nil, include_production_only: true, strict: false)
119
126
  selected = products
120
127
  selected = selected.select { |entry| entry.key == product.to_s } if product.present?
121
128
  selected = selected.flat_map(&:sets)
122
129
  selected = selected.select { |entry| entry.key == set.to_s } if set.present?
123
130
  selected = selected.flat_map(&:scenarios)
124
- selected = selected.select { |scenario| Array(keys).map(&:to_s).include?(scenario.key) } if keys.present?
131
+ if strict
132
+ selected = Suite.select(selected, keys: keys, known: all_scenarios, name: key)
133
+ elsif keys.present?
134
+ selected = selected.select { |scenario| Array(keys).map(&:to_s).include?(scenario.key) }
135
+ end
125
136
  selected = selected.reject(&:production_only?) unless include_production_only
126
137
  selected
127
138
  end
@@ -136,16 +147,24 @@ module ActiveAgent
136
147
 
137
148
  # One product's sets as a Suite (every set a group), or one set alone,
138
149
  # for code that takes a suite: `Runner.new(scenarios: catalog.suite_for(:triage).scenarios, ...)`.
150
+ # The product's `pass` is the suite's and each set's its group's.
139
151
  def suite_for(product_key, set_key = nil)
140
152
  found = product(product_key)
141
153
  sets = set_key ? [ set(product_key, set_key) ] : found.sets
142
154
  Suite.new([ {
143
155
  "suite" => [ key, found.key, set_key ].compact.join("/"),
144
156
  "description" => found.description || description,
157
+ "criteria" => found.criteria,
158
+ "pass" => found.pass,
145
159
  "groups" => sets.map { |entry| set_document(entry) }
146
160
  } ])
147
161
  end
148
162
 
163
+ # The bars a product, or one of its sets, declares (PassThresholds).
164
+ def pass_thresholds(product_key, set_key = nil)
165
+ suite_for(product_key, set_key).pass_thresholds
166
+ end
167
+
149
168
  # The canonical document: keys in a fixed order, nothing empty, so the
150
169
  # same content always reads the same.
151
170
  def to_h
@@ -182,8 +201,10 @@ module ActiveAgent
182
201
  "key" => product_key,
183
202
  "name" => document["name"].presence || product_key,
184
203
  "description" => document["description"],
204
+ "criteria" => document["criteria"],
205
+ "pass" => document["pass"],
185
206
  "sets" => Array(document["groups"])
186
- } ]
207
+ }.compact ]
187
208
  }.compact
188
209
  end
189
210
 
@@ -196,16 +217,70 @@ module ActiveAgent
196
217
  existing = products.find { |product| product["key"] == incoming["key"].to_s }
197
218
  if existing
198
219
  %w[name description agent].each { |field| existing[field] = incoming[field] if incoming[field].present? }
220
+ merge_pass(existing, incoming["pass"], "product #{existing['key'].inspect}")
199
221
  existing.merge!(incoming.except(*PRODUCT_KEYS))
222
+ merge_criteria_lists(existing, incoming)
200
223
  merge_sets(existing, Array(incoming["sets"]))
201
224
  else
202
- products << incoming.merge("key" => incoming["key"].to_s, "sets" => [])
225
+ products << incoming.merge("key" => incoming["key"].to_s, "criteria" => {}, "sets" => [])
226
+ .tap { |product| merge_criteria_lists(product, incoming) }
203
227
  .tap { |product| merge_sets(product, Array(incoming["sets"])) }
204
228
  end
205
229
  end
206
230
  end
207
231
  end
208
232
 
233
+ # Layers a product document's named criteria lists over the product's:
234
+ # by name, and within a name by criterion key.
235
+ def merge_criteria_lists(product, incoming)
236
+ criteria_lists(incoming).each do |list_name, list|
237
+ merge_criteria(product["criteria"][list_name] ||= [], list, "list #{list_name.inspect} of product #{product['key'].inspect}")
238
+ end
239
+ end
240
+
241
+ # A product document's `criteria` as `name => list`; a bare list is the
242
+ # one list named "default", and an empty one declares no list.
243
+ def criteria_lists(product)
244
+ value = product["criteria"]
245
+ return {} if value.nil? || value == []
246
+
247
+ value = { "default" => value } if value.is_a?(Array)
248
+ unless value.is_a?(Hash)
249
+ raise InvalidDocument, "the criteria of product #{product['key'].inspect} are not a list or a mapping of lists"
250
+ end
251
+
252
+ value.to_h do |list_name, list|
253
+ raise InvalidDocument, "criteria list #{list_name.inspect} of product #{product['key'].inspect} is not a list" unless list.is_a?(Array)
254
+
255
+ [ list_name.to_s, list ]
256
+ end
257
+ end
258
+
259
+ # Layers `incoming` criteria over `existing` by key: a criterion with a
260
+ # key already present replaces it in place and a new key is appended;
261
+ # an empty or missing `incoming` declares none. A criterion without a
262
+ # key is appended once, when `allow_keyless` is true, and refused
263
+ # otherwise.
264
+ def merge_criteria(existing, incoming, owner, allow_keyless: false)
265
+ return if incoming.blank?
266
+ raise InvalidDocument, "the criteria of #{owner} are not a list" unless incoming.is_a?(Array)
267
+
268
+ incoming.each do |criterion|
269
+ raise InvalidDocument, "a criterion in #{owner} is not a mapping" unless criterion.is_a?(Hash)
270
+
271
+ criterion = criterion.deep_stringify_keys
272
+ if criterion["key"].present?
273
+ criterion["key"] = criterion["key"].to_s
274
+ index = existing.index { |entry| entry["key"] == criterion["key"] }
275
+ index ? existing[index] = criterion : existing << criterion
276
+ elsif allow_keyless
277
+ existing << criterion unless existing.include?(criterion)
278
+ else
279
+ raise InvalidDocument, "a criterion in #{owner} has no key"
280
+ end
281
+ end
282
+ end
283
+
209
284
  def merge_sets(product, incoming_sets)
210
285
  incoming_sets.each do |incoming|
211
286
  incoming = incoming.to_h.deep_stringify_keys
@@ -213,16 +288,31 @@ module ActiveAgent
213
288
 
214
289
  existing = product["sets"].find { |set| set["key"] == incoming["key"].to_s }
215
290
  if existing
216
- %w[name description judge criteria].each { |field| existing[field] = incoming[field] if incoming[field].present? }
291
+ %w[name description judge].each { |field| existing[field] = incoming[field] if incoming[field].present? }
292
+ merge_pass(existing, incoming["pass"], "set #{existing['key'].inspect} of product #{product['key'].inspect}")
217
293
  existing.merge!(incoming.except(*SET_KEYS))
294
+ merge_set_criteria(existing, incoming, product)
218
295
  merge_scenarios(existing, Array(incoming["scenarios"]))
219
296
  else
220
- product["sets"] << incoming.merge("key" => incoming["key"].to_s, "scenarios" => [])
297
+ product["sets"] << incoming.merge("key" => incoming["key"].to_s, "criteria" => [], "scenarios" => [])
298
+ .tap { |set| merge_set_criteria(set, incoming, product) }
221
299
  .tap { |set| merge_scenarios(set, Array(incoming["scenarios"])) }
222
300
  end
223
301
  end
224
302
  end
225
303
 
304
+ # A later document's `pass` layers over the existing one key by key.
305
+ def merge_pass(existing, incoming, place)
306
+ incoming = pass_block(incoming, place)
307
+ existing["pass"] = pass_block(existing["pass"], place).merge(incoming) if incoming.any?
308
+ end
309
+
310
+ # Layers a set document's `criteria` over the set's by key; a set keeps
311
+ # a criterion without a key.
312
+ def merge_set_criteria(set, incoming, product)
313
+ merge_criteria(set["criteria"], incoming["criteria"], "set #{set['key'].inspect} of product #{product['key'].inspect}", allow_keyless: true)
314
+ end
315
+
226
316
  def merge_scenarios(set, incoming_scenarios)
227
317
  incoming_scenarios.each do |scenario|
228
318
  scenario = scenario.to_h.deep_stringify_keys
@@ -237,39 +327,67 @@ module ActiveAgent
237
327
 
238
328
  def build_products(documents)
239
329
  documents.each_with_index.map do |product, position|
330
+ sets = product["sets"].each_with_index.map { |set, set_position| build_set(set, set_position, product["key"]) }
240
331
  Product.new(
241
332
  key: product["key"],
242
333
  name: product["name"].presence || product["key"],
243
334
  description: product["description"].presence,
244
335
  agent: product["agent"].presence&.to_s,
336
+ criteria: product["criteria"],
337
+ pass: validated_pass(product["pass"], PassThresholds::SUITE_KEYS, "product #{product['key'].inspect}"),
245
338
  metadata: product.except(*PRODUCT_KEYS),
246
339
  position: position,
247
- sets: product["sets"].each_with_index.map { |set, set_position| build_set(set, set_position) }
340
+ sets: sets
248
341
  )
249
342
  end
250
343
  end
251
344
 
252
- def build_set(set, position)
345
+ def build_set(set, position, product_key)
253
346
  Set.new(
254
347
  key: set["key"],
255
348
  name: set["name"].presence || set["key"],
256
349
  description: set["description"].presence,
257
350
  judge: (set["judge"] || {}).to_h.stringify_keys,
258
- criteria: Array(set["criteria"]).map { |criterion| criterion.to_h.deep_stringify_keys },
351
+ criteria: set["criteria"],
352
+ pass: validated_pass(set["pass"], PassThresholds::GROUP_KEYS, "set #{set['key'].inspect} of product #{product_key.inspect}"),
259
353
  metadata: set.except(*SET_KEYS),
260
354
  position: position,
261
355
  scenarios: set["scenarios"].each_with_index.map do |entry, index|
262
- Scenario.from_hash(entry.merge("position" => index), group: set["key"], group_name: set["name"].presence || set["key"])
356
+ Scenario.from_hash(entry.merge("position" => index), group: set["key"], group_name: set["name"].presence || set["key"],
357
+ metadata: entry.except(*SCENARIO_KEYS))
263
358
  end
264
359
  )
265
360
  end
266
361
 
362
+ # A `pass` block as a string-keyed Hash, {} when absent, checked the
363
+ # way PassThresholds reads it (`allowed` is SUITE_KEYS for a product,
364
+ # GROUP_KEYS for a set) so a block the suite would refuse fails the
365
+ # catalog as it loads rather than the first run that asks for it; a
366
+ # refusal names the product or set the block sits on.
367
+ def validated_pass(value, allowed, place)
368
+ pass = pass_block(value, place)
369
+ PassThresholds.validate(pass, allowed, place)
370
+ pass
371
+ rescue ArgumentError => e
372
+ raise InvalidDocument, e.message
373
+ end
374
+
375
+ # A `pass` block as a string-keyed Hash, {} when absent; anything but
376
+ # a mapping is refused naming the product or set it sits on.
377
+ def pass_block(value, place)
378
+ PassThresholds.pass_block(value, place)
379
+ rescue ArgumentError => e
380
+ raise InvalidDocument, e.message
381
+ end
382
+
267
383
  def product_document(product)
268
384
  compact({
269
385
  "key" => product.key,
270
386
  "name" => product.name,
271
387
  "description" => product.description,
272
388
  "agent" => product.agent,
389
+ "criteria" => product.criteria,
390
+ "pass" => product.pass,
273
391
  "sets" => product.sets.map { |set| set_document(set) }
274
392
  }.merge(product.metadata))
275
393
  end
@@ -281,6 +399,7 @@ module ActiveAgent
281
399
  "description" => set.description,
282
400
  "judge" => set.judge,
283
401
  "criteria" => set.criteria,
402
+ "pass" => set.pass,
284
403
  "scenarios" => set.scenarios.map { |scenario| scenario_document(scenario) }
285
404
  }.merge(set.metadata))
286
405
  end
@@ -294,7 +413,19 @@ module ActiveAgent
294
413
  "tags" => scenario.tags,
295
414
  "params" => scenario.params,
296
415
  "production_only" => (true if scenario.production_only?)
297
- })
416
+ }).merge(canonical_metadata(scenario.metadata.to_h.deep_stringify_keys.except(*SCENARIO_KEYS)))
417
+ end
418
+
419
+ # JSON object storage may reorder keys; array order remains meaningful.
420
+ def canonical_metadata(value)
421
+ case value
422
+ when Hash
423
+ value.keys.sort.to_h { |key| [ key, canonical_metadata(value[key]) ] }
424
+ when Array
425
+ value.map { |entry| canonical_metadata(entry) }
426
+ else
427
+ value
428
+ end
298
429
  end
299
430
 
300
431
  def compact(hash)
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # A report's pass gates as one check with the message a failing
6
+ # assertion prints, so TestHelper#assert_meets_pass_gates and the RSpec
7
+ # matcher (`active_agent/evals/rspec`) fail with the same text: how many
8
+ # gates are not met, each of them as a sentence, then the failures to
9
+ # read under a heading per group.
10
+ #
11
+ # check = ActiveAgent::Evals::GateCheck.new(report)
12
+ # check.met? # => false
13
+ # puts check.message
14
+ # # 2 of 5 pass gates not met
15
+ # # - Order support · gpt-5-mini: 1/2 (50%) passed, below 100%
16
+ # # - All · gpt-5-mini: 3/4 (75%) passed, below 80%
17
+ # #
18
+ # # Order support failures:
19
+ # # - `order_status` on `gpt-5-mini`: low quality — Scored 50% against a pass threshold of 70%, weakest on response_present (100%).
20
+ class GateCheck
21
+ # @param report [Report] the report whose gates are checked
22
+ # @param thresholds [PassThresholds, nil] the bars to hold the report
23
+ # to; without them Report#gates decides, taking the report's own,
24
+ # else the defaults. Only their `min_pass_rate` and `gated` apply:
25
+ # whether an answer passed was settled at the threshold the Runner
26
+ # scored it at, so a different `threshold` here changes nothing.
27
+ def initialize(report, thresholds = nil)
28
+ @report = report
29
+ @bars = [ thresholds ].compact
30
+ end
31
+
32
+ # Whether every gate is met.
33
+ def met?
34
+ @report.passed?(*@bars)
35
+ end
36
+
37
+ # Every gate, in Report#gates order.
38
+ def gates
39
+ @report.gates(*@bars)
40
+ end
41
+
42
+ # The gates that are not met, in gates order.
43
+ def unmet
44
+ gates.reject(&:met?)
45
+ end
46
+
47
+ # The text a failing assertion prints. The first line counts the gates
48
+ # not met, or is the one line "every pass gate met" when none is; then
49
+ # each unmet gate as Gate#to_s; then, after a blank line, the failures
50
+ # to read: for each group with an unmet gate, in gates order, "<group>
51
+ # failures:" over Report#failures_markdown for that group. An unmet All
52
+ # row adds the failures it counts, those in gated groups or in no
53
+ # group: under "Failures:" when no group row is unmet, else the ones
54
+ # outside the groups already listed under "Other failures:", when there
55
+ # are any. An ungated group's failures count toward no gate and are
56
+ # never listed.
57
+ def message
58
+ all = gates
59
+ return "no result reached a pass gate; the run evaluated nothing" if all.empty?
60
+
61
+ unmet = all.reject(&:met?)
62
+ return "every pass gate met" if unmet.empty?
63
+
64
+ lines = [ "#{unmet.size} of #{all.size} pass gates not met", *unmet.map { |gate| "- #{gate}" } ]
65
+ grouped = unmet.reject { |gate| gate.group.nil? }.uniq(&:group)
66
+ grouped.each { |gate| lines << "" << "#{gate.name} failures:" << @report.failures_markdown(group: gate.group) }
67
+ if unmet.any? { |gate| gate.group.nil? }
68
+ ungated = all.reject(&:gated).map(&:group)
69
+ listed = grouped.map(&:group)
70
+ others = @report.failures.reject { |result| (ungated + listed).include?(result.scenario.group) }
71
+ heading = listed.empty? ? "Failures:" : "Other failures:"
72
+ lines << "" << heading << others.map { |result| @report.failure_markdown(result) }.join("\n") if others.any?
73
+ end
74
+ lines.join("\n")
75
+ end
76
+ alias to_s message
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # The bars a suite declares for a run: the score every answer must reach
6
+ # (the Runner's and the Report's threshold), the share of a model's
7
+ # answers that must pass, and the groups whose results are reported but
8
+ # not gated. Report#gates compares each group's pass rate against them.
9
+ #
10
+ # suite: support_desk
11
+ # pass:
12
+ # threshold: 0.7
13
+ # min_pass_rate: 0.8
14
+ # groups:
15
+ # - key: orders
16
+ # pass: { min_pass_rate: 1.0 }
17
+ # - key: experimental
18
+ # pass: { gated: false }
19
+ #
20
+ # A group's `min_pass_rate` falls back to the suite's, and the suite's to
21
+ # DEFAULT_MIN_PASS_RATE; a group is gated unless it says `gated: false`.
22
+ # Layered documents override key by key at both levels, so a document
23
+ # that sets one group's bar leaves the suite's threshold and the other
24
+ # groups alone.
25
+ class PassThresholds
26
+ DEFAULT_MIN_PASS_RATE = 1.0
27
+ SUITE_KEYS = %w[threshold min_pass_rate].freeze
28
+ GROUP_KEYS = %w[min_pass_rate gated].freeze
29
+
30
+ # Layers each document's top-level `pass` and each group's `pass`
31
+ # (groups under `groups`, keyed by their `key`), later documents
32
+ # winning key by key. A group without a key has no scenarios to gate,
33
+ # so a `pass` block on one is refused.
34
+ #
35
+ # @param documents [Array<Hash>] parsed YAML documents, base first
36
+ def self.from_documents(documents)
37
+ suite = {}
38
+ groups = {}
39
+ Array(documents).each do |document|
40
+ document = document.to_h.deep_stringify_keys
41
+ suite.merge!(pass_block(document["pass"], "the suite"))
42
+ Array(document["groups"]).each do |group|
43
+ group = group.to_h.deep_stringify_keys
44
+ key = group["key"].to_s
45
+ raise ArgumentError, "\"pass\" of a group without a key gates nothing" if key.empty? && !group["pass"].nil?
46
+
47
+ groups[key] = groups.fetch(key, {}).merge(pass_block(group["pass"], "group #{key}"))
48
+ end
49
+ end
50
+ new(suite: suite, groups: groups)
51
+ end
52
+
53
+ def self.default
54
+ new
55
+ end
56
+
57
+ # A `pass` block as a string-keyed Hash; nothing declared reads as {},
58
+ # and anything but a mapping raises ArgumentError naming `place`.
59
+ def self.pass_block(value, place)
60
+ return {} if value.nil?
61
+ raise ArgumentError, "\"pass\" of #{place} must be a mapping, got #{value.inspect}" unless value.is_a?(Hash)
62
+
63
+ value.deep_stringify_keys
64
+ end
65
+
66
+ # `pass` as a string-keyed Hash of the `allowed` keys (SUITE_KEYS or
67
+ # GROUP_KEYS), each refusal naming `place` — the suite, a group, or
68
+ # whatever a caller holds a block for: a bar is a number from 0 to 1,
69
+ # kept as a Float so `1` and `1.0` read, serialize and hash the same,
70
+ # and `gated` is true or false.
71
+ def self.validate(pass, allowed, place)
72
+ pass_block(pass, place).to_h do |key, value|
73
+ raise ArgumentError, "unknown key #{key.inspect} in \"pass\" of #{place}; expected one of #{allowed.join(', ')}" unless allowed.include?(key)
74
+
75
+ if key == "gated"
76
+ raise ArgumentError, "\"gated\" in \"pass\" of #{place} must be true or false, got #{value.inspect}" unless [ true, false ].include?(value)
77
+
78
+ [ key, value ]
79
+ elsif value.is_a?(Numeric) && value.finite? && value.between?(0, 1)
80
+ [ key, value.to_f ]
81
+ else
82
+ raise ArgumentError, "#{key.inspect} in \"pass\" of #{place} must be a number from 0 to 1, got #{value.inspect}"
83
+ end
84
+ end
85
+ end
86
+
87
+ # @param suite [Hash] the suite's bars, `threshold` and `min_pass_rate`
88
+ # @param groups [Hash{String=>Hash}] each group's bars by group key,
89
+ # `min_pass_rate` and `gated`
90
+ def initialize(suite: {}, groups: {})
91
+ @suite = self.class.validate(suite, SUITE_KEYS, "the suite")
92
+ @groups = groups.to_h.deep_stringify_keys.filter_map do |key, pass|
93
+ declared = self.class.validate(pass, GROUP_KEYS, "group #{key}")
94
+ [ key.to_s, declared ] if declared.any?
95
+ end.to_h
96
+ end
97
+
98
+ # The score every answer must reach, 0..1.
99
+ def threshold
100
+ (@suite["threshold"] || PASS_THRESHOLD).to_f
101
+ end
102
+
103
+ # The share of a model's answers that must pass, 0..1: the group's own
104
+ # bar when it declared one, else the suite's, else DEFAULT_MIN_PASS_RATE.
105
+ def min_pass_rate(group = nil)
106
+ own = @groups.dig(group.to_s, "min_pass_rate") unless group.nil?
107
+ (own || @suite["min_pass_rate"] || DEFAULT_MIN_PASS_RATE).to_f
108
+ end
109
+
110
+ # False only for a group that declared `gated: false`; nil and unknown
111
+ # groups are gated.
112
+ def gated?(group)
113
+ return true if group.nil?
114
+
115
+ @groups.dig(group.to_s, "gated") != false
116
+ end
117
+
118
+ # The bars in effect, with each group's declared keys only.
119
+ def to_h
120
+ {
121
+ "threshold" => threshold,
122
+ "min_pass_rate" => min_pass_rate,
123
+ "groups" => @groups.transform_values(&:dup)
124
+ }
125
+ end
126
+
127
+ def ==(other)
128
+ other.is_a?(PassThresholds) && to_h == other.to_h
129
+ end
130
+ alias eql? ==
131
+
132
+ def hash
133
+ to_h.hash
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # Regexes for a figure an answer is expected to state, built from the data
6
+ # the scenario was seeded with: a count ("3 of 4 tickets") or a share of
7
+ # a total ("75%"). Each is a String that drops into a suite's
8
+ # `expect.contains` / `not_contains` or a `contains` criterion, which
9
+ # Scorer.matches_pattern? tries as a substring and then as a regex:
10
+ #
11
+ # "expect" => { "contains" => [ Patterns.number(3), Patterns.share(3, 4) ] }
12
+ module Patterns
13
+ MONTHS = %w[january february march april may june july august september october november december].freeze
14
+
15
+ # `text` as a pattern that matches it in any case, each letter a
16
+ # character class: any_case("Oct.") is "[Oo][Cc][Tt]\\.".
17
+ def self.any_case(text)
18
+ text.chars.map { |char| char.match?(/[a-z]/i) ? "[#{char.upcase}#{char.downcase}]" : Regexp.escape(char) }.join
19
+ end
20
+ private_class_method :any_case
21
+
22
+ # A day of the month written beside its month, in any case: "Oct 12",
23
+ # "Sept. 12" and "October 12" before the figure, "12 Oct" and "12th of
24
+ # October" after it. A lookbehind takes alternatives of one length only,
25
+ # so the spellings before the figure are one lookbehind per length, and
26
+ # each letter in it is a character class rather than `(?i:...)`: the
27
+ # dashes make the pattern UTF-8, where case folding lets "st" match the
28
+ # one-character ligature "st", and Ruby 3.2 refuses a lookbehind whose
29
+ # length that leaves open. "may" is left out after the figure, where it
30
+ # is usually the verb.
31
+ MONTH_BEFORE = MONTHS.flat_map { |month| [ month, month[0, 3], "#{month[0, 3]}." ] }.push("sept", "sept.").uniq
32
+ .group_by(&:length).sort.map { |_length, spellings| "(?<!#{spellings.map { |spelling| "\\b#{any_case("#{spelling} ")}" }.join('|')})" }
33
+ .join.freeze
34
+ MONTH_AFTER = (MONTHS - %w[may]).map { |month| "#{month[0, 3]}(?:#{Regexp.escape(month[3..])})?" }.push("sept").join("|").freeze
35
+
36
+ module_function
37
+
38
+ # The integer `count` as a figure on its own: number(10) matches
39
+ # "10 sessions", "(10)" and "10." at a sentence end, not "2026-10-01",
40
+ # "10/1", "10:00", "1-10" or "1–10", "110", "10.5", "10,000", "10%",
41
+ # "Oct 10" or "10 October". A ratio such as "10/22" does not match
42
+ # either, because "10/12" reads as a date as often as a count; expect
43
+ # "10 of 22". `count` is read as a whole number from its text, so 3 and
44
+ # "3" work and 1.5, 10.0, "a" or a negative count raise ArgumentError.
45
+ def number(count)
46
+ count = whole_number(count, "count")
47
+ "(?<![\\d/:.,\\-\u2013\u2014])#{MONTH_BEFORE}\\b#{count}\\b(?![/:\\-\u2013\u2014])(?![.,]\\d)(?! ?%)" \
48
+ "(?!(?i:(?:st|nd|rd|th)?,? (?:of )?(?:#{MONTH_AFTER})\\b))"
49
+ end
50
+
51
+ # `count` of `total` as a percentage, whole or to one or two decimals,
52
+ # with an optional space before the sign: share(3, 4) matches "75%",
53
+ # "75 %", "75.0%" and "75.00%", not "175%", "7.5%", "75.5%" or "0.75".
54
+ # The whole percent rounds half up like Format.whole and the decimals
55
+ # like Float#round, so share(1, 8) matches "13%", "12.5%" and "12.50%",
56
+ # and share(12, 22) matches "55%", "54.5%" and "54.55%". Both arguments are read like
57
+ # `number`'s; a total below one and a count above the total raise
58
+ # ArgumentError.
59
+ def share(count, total)
60
+ count = whole_number(count, "count")
61
+ total = whole_number(total, "total", minimum: 1)
62
+ raise ArgumentError, "count must not exceed total" if count > total
63
+
64
+ percent = count * 100.0 / total
65
+ decimals = [ 2, 1 ].map { |places| Regexp.escape(format("%.#{places}f", percent.round(places))) }
66
+ "(?<![\\d.,])\\b(?:#{decimals.join('|')}|#{Format.whole(percent)}) ?%"
67
+ end
68
+
69
+ # `value` as an Integer of at least `minimum`, read from its text so
70
+ # that 1.5 is rejected rather than truncated: 3 and "3" → 3; 1.5, "a"
71
+ # and a value below `minimum` raise ArgumentError.
72
+ def whole_number(value, name, minimum: 0)
73
+ number = Integer(value.to_s, 10)
74
+ raise ArgumentError, "#{name} must be #{minimum.positive? ? 'positive' : 'zero or positive'}" if number < minimum
75
+
76
+ number
77
+ end
78
+ end
79
+ end
80
+ end