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
@@ -30,8 +30,9 @@ module ActiveAgent
30
30
  # tool to add, or how to change the instructions.
31
31
  DEFAULT_REFINE_FAULTS = %w[missing_capability expected_tool_not_called ungrounded_answer low_quality missing_content].freeze
32
32
  DEFAULT_JUDGE_LIMIT = 25
33
+ DEFAULT_TRIALS = 1
33
34
 
34
- attr_reader :scenarios, :models, :criteria, :judge, :threshold
35
+ attr_reader :scenarios, :models, :criteria, :judge, :threshold, :pass_thresholds, :judge_failed_expectations, :trials
35
36
 
36
37
  # @param scenarios [Array<Scenario>]
37
38
  # @param models [Array<ModelSpec>]
@@ -43,6 +44,12 @@ module ActiveAgent
43
44
  # @param available_tools [Hash{String=>String}, Array<String>] the agent's tool roster
44
45
  # @param instructions [String, nil] the agent's instructions, for the judge
45
46
  # @param agent_name [String] how recommendations refer to the agent
47
+ # @param threshold [Float, nil] the score an answer must reach to pass;
48
+ # an explicit value wins over the `pass_thresholds` bar, and without
49
+ # either it is PASS_THRESHOLD
50
+ # @param pass_thresholds [PassThresholds, nil] the bars the suite
51
+ # declared (Suite#pass_thresholds): its threshold applies when none
52
+ # is given here, and the Report gates each group by it (Report#gates)
46
53
  # @param on_result [#call, nil] called with each Result as it lands
47
54
  # @param around_evaluation [#call, nil] called with (scenario, spec) and a
48
55
  # block that returns the Result. Establishes context for replay, scoring
@@ -58,20 +65,38 @@ module ActiveAgent
58
65
  # after `judge_limit`; false skips it. A caller that scores each
59
66
  # scenario in its own Runner (a background job per scenario) shares one
60
67
  # run-wide limit this way, since `judge_limit` counts per instance.
68
+ # @param judge_failed_expectations [Boolean] whether the judge grades a
69
+ # replay that missed one of its scenario's expectations (an expected
70
+ # tool not called, forbidden or missing content, a failed tool call).
71
+ # false skips its task grade, its llm_judge criteria and its judged
72
+ # recommendation, since it fails whatever the judge says, and the
73
+ # calls saved do not count against `judge_limit`. The default grades
74
+ # every answered replay.
75
+ # @param trials [Integer] how many times each scenario is replayed under
76
+ # each model, 1 or more. Every replay is a Result of its own under the
77
+ # scenario's key, told apart by Result#trial, so a flaky scenario
78
+ # reads as a pass share rather than one pass or fail.
61
79
  def initialize(scenarios:, models:, replay:, criteria: [], judge: nil, judge_task: true, available_tools: {},
62
- instructions: nil, agent_name: "The agent", threshold: PASS_THRESHOLD,
80
+ instructions: nil, agent_name: "The agent", threshold: nil, pass_thresholds: nil,
63
81
  refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil,
64
- around_evaluation: nil, require_judge_scores: false, metadata: {}, release: nil, refine_gate: nil)
82
+ around_evaluation: nil, require_judge_scores: false, metadata: {}, release: nil, refine_gate: nil,
83
+ judge_failed_expectations: true, trials: DEFAULT_TRIALS)
84
+ unless trials.is_a?(Integer) && trials >= 1
85
+ raise ArgumentError, "trials must be an Integer of 1 or more, got #{trials.inspect}"
86
+ end
87
+
65
88
  @scenarios = scenarios
66
89
  @models = models
67
90
  @replay = replay
91
+ @trials = trials
68
92
  @criteria = criteria
69
93
  @judge = judge
70
94
  @judge_task = judge_task
71
95
  @available_tools = normalize_tools(available_tools)
72
96
  @instructions = instructions
73
97
  @agent_name = agent_name
74
- @threshold = threshold
98
+ @threshold = threshold || pass_thresholds&.threshold || PASS_THRESHOLD
99
+ @pass_thresholds = pass_thresholds
75
100
  @refine_faults = refine_faults
76
101
  @judge_limit = judge_limit
77
102
  @on_result = on_result
@@ -80,27 +105,33 @@ module ActiveAgent
80
105
  @metadata = metadata
81
106
  @release = release
82
107
  @refine_gate = refine_gate
108
+ @judge_failed_expectations = judge_failed_expectations
83
109
  @judge_calls = 0
84
- @scorer = Scorer.new(criteria: criteria, judge: judge)
110
+ @scorer = Scorer.new(criteria: criteria, judge: judge, judge_failed_expectations: judge_failed_expectations)
85
111
  end
86
112
 
113
+ # Replays every scenario under every model `trials` times: scenario by
114
+ # scenario, model by model, trial 1 to `trials`.
87
115
  def call
88
116
  results = @scenarios.flat_map do |scenario|
89
- @models.map do |spec|
90
- evaluate_with_context(scenario, spec).tap { |result| @on_result&.call(result) }
117
+ @models.flat_map do |spec|
118
+ (1..@trials).map do |trial|
119
+ evaluate_with_context(scenario, spec, trial: trial).tap { |result| @on_result&.call(result) }
120
+ end
91
121
  end
92
122
  end
93
123
 
94
124
  Report.new(results: results, models: @models, judge: @judge, instructions: @instructions,
95
- threshold: @threshold, metadata: @metadata, release: @release)
125
+ threshold: @threshold, metadata: @metadata, release: @release, pass_thresholds: @pass_thresholds)
96
126
  end
97
127
 
98
128
  # Scores and diagnoses one replay. Public so a caller that has already run
99
129
  # the agent (a background job per scenario, say) can score without the loop.
100
- def evaluate(scenario, spec, replay = nil)
130
+ # `trial` numbers the Result when the caller replays a scenario more than once.
131
+ def evaluate(scenario, spec, replay = nil, trial: 1)
101
132
  replay ||= run_replay(scenario, spec)
102
133
  scores = @scorer.score(scenario, replay)
103
- if judge_task? && replay.answer.present?
134
+ if judge_task? && replay.answer.present? && judge_answer?(scores)
104
135
  scores["task_completion"] = @judge.score_task(scenario: scenario, answer: replay.answer)
105
136
  end
106
137
  score = Scorer.mean(scores)
@@ -110,7 +141,7 @@ module ActiveAgent
110
141
  judge_keys: llm_judge_keys)
111
142
  diagnosis ||= unavailable_judge_diagnosis(scores)
112
143
  diagnosis_hash = diagnosis&.to_h
113
- refine!(diagnosis_hash, scenario, replay, diagnosis) if diagnosis_hash
144
+ refine!(diagnosis_hash, scenario, replay, diagnosis, scores) if diagnosis_hash
114
145
 
115
146
  Result.new(
116
147
  scenario: scenario,
@@ -119,16 +150,17 @@ module ActiveAgent
119
150
  scores: scores,
120
151
  score: score,
121
152
  status: replay.errored? ? "errored" : (diagnosis ? "failed" : "passed"),
122
- diagnosis: diagnosis_hash
153
+ diagnosis: diagnosis_hash,
154
+ trial: trial
123
155
  )
124
156
  end
125
157
 
126
158
  private
127
159
 
128
- def evaluate_with_context(scenario, spec)
129
- return evaluate(scenario, spec) unless @around_evaluation
160
+ def evaluate_with_context(scenario, spec, trial:)
161
+ return evaluate(scenario, spec, trial: trial) unless @around_evaluation
130
162
 
131
- result = @around_evaluation.call(scenario, spec) { evaluate(scenario, spec) }
163
+ result = @around_evaluation.call(scenario, spec) { evaluate(scenario, spec, trial: trial) }
132
164
  # A wrapper written the natural way — do something, yield, do something
133
165
  # after — returns that last value rather than the Result. Left alone it
134
166
  # reaches on_result and the Report, and fails somewhere far from the
@@ -144,6 +176,16 @@ module ActiveAgent
144
176
  @judge && @judge_task && @criteria.none? { |criterion| criterion.to_h.stringify_keys["type"] == "llm_judge" }
145
177
  end
146
178
 
179
+ # Whether the judge is asked about a replay with these scores: always by
180
+ # default, or only when it met every expectation of its scenario. A
181
+ # replay that did not already carries one of the faults Diagnosis
182
+ # assigns ahead of low_quality (tool_error, missing_capability,
183
+ # expected_tool_not_called, ungrounded_answer, forbidden_content or
184
+ # missing_content), so its status is failed whatever the judge would say.
185
+ def judge_answer?(scores)
186
+ @judge_failed_expectations || Scorer.expectations_met?(scores)
187
+ end
188
+
147
189
  # The keys in `scores` a judge graded, so a low grade is not averaged
148
190
  # away against rule checks.
149
191
  def llm_judge_keys
@@ -189,8 +231,9 @@ module ActiveAgent
189
231
  end
190
232
  end
191
233
 
192
- def refine!(diagnosis, scenario, replay, result)
234
+ def refine!(diagnosis, scenario, replay, result, scores)
193
235
  return unless @judge && @refine_faults.include?(result.fault)
236
+ return unless judge_answer?(scores)
194
237
  return if @judge_calls >= @judge_limit
195
238
  return if @refine_gate && !@refine_gate.call
196
239
 
@@ -24,16 +24,21 @@ module ActiveAgent
24
24
  # @return [Array<String>] labels a catalog files the scenario under ("billing")
25
25
  # @!attribute params
26
26
  # @return [Hash] free-form parameters a replay may read; kept as written
27
+ # @!attribute metadata
28
+ # @return [Hash, nil] opaque extension fields from a catalog document
27
29
  Scenario = Struct.new(:key, :group, :group_name, :prompt, :expected_tools, :expected_patterns,
28
- :forbidden_patterns, :notes, :production_only, :position, :tags, :params,
30
+ :forbidden_patterns, :notes, :production_only, :position, :tags, :params, :metadata,
29
31
  keyword_init: true) do
30
32
  # Builds a scenario from a hash — ScenarioParser output, a suite entry, or
31
33
  # a persisted record's attributes. Expectations are read from an
32
34
  # `expectations`/`expect` sub-hash (`tools`, `contains`, `not_contains`)
33
35
  # or from those keys at the top level.
34
- def self.from_hash(attributes, group: nil, group_name: nil)
36
+ # Pass +metadata+ when extension fields have been separated from a
37
+ # document; its literal `metadata` key may itself be an extension.
38
+ def self.from_hash(attributes, group: nil, group_name: nil, metadata: nil)
35
39
  attrs = attributes.to_h.deep_stringify_keys
36
40
  expect = (attrs["expectations"] || attrs["expect"] || {}).to_h.stringify_keys
41
+ metadata = attrs["metadata"] if metadata.nil? && attrs["metadata"].is_a?(Hash)
37
42
 
38
43
  new(
39
44
  key: attrs["key"].to_s,
@@ -47,7 +52,8 @@ module ActiveAgent
47
52
  production_only: attrs["production_only"] == true,
48
53
  position: attrs["position"],
49
54
  tags: list(attrs["tags"]),
50
- params: (attrs["params"] || {}).to_h.deep_stringify_keys
55
+ params: (attrs["params"] || {}).to_h.deep_stringify_keys,
56
+ metadata: (metadata || {}).to_h.deep_stringify_keys
51
57
  )
52
58
  end
53
59
 
@@ -68,8 +74,10 @@ module ActiveAgent
68
74
  end
69
75
 
70
76
  def to_h
71
- super.compact.reject { |field, value| %i[tags params].include?(field) && value.blank? }
77
+ super.compact.reject { |field, value| %i[tags params metadata].include?(field) && value.blank? }
72
78
  end
73
79
  end
80
+
81
+ Scenario::DOCUMENT_KEYS = %w[key prompt expect expectations tools contains not_contains notes tags params production_only position].freeze
74
82
  end
75
83
  end
@@ -138,12 +138,20 @@ module ActiveAgent
138
138
  end
139
139
  end
140
140
 
141
- Suite.new([ document ]).all_scenarios.map do |item|
141
+ suite_from(document).all_scenarios.map do |item|
142
142
  scenario(prompt: item.prompt, group: item.group, group_name: item.group_name, key: item.key,
143
143
  notes: item.notes, expectations: item.expectations, production_only: item.production_only?)
144
144
  end
145
145
  end
146
146
 
147
+ # The document as a Suite; a `criteria` block the Suite refuses is a
148
+ # ParseError, as every other fault in a pasted document is.
149
+ def suite_from(document)
150
+ Suite.new([ document ])
151
+ rescue ArgumentError => e
152
+ raise ParseError, e.message
153
+ end
154
+
147
155
  def scenario_from_hash(entry)
148
156
  entry = entry.stringify_keys
149
157
  prompt = entry["prompt"] || entry["message"] || entry["input"] || entry["question"]
@@ -4,7 +4,8 @@ module ActiveAgent
4
4
  module Evals
5
5
  # Scores one Replay against an evaluation's criteria and the scenario's
6
6
  # expectations. Returns `criterion key => 0.0..1.0`, with nil for a
7
- # criterion that could not be scored (an llm_judge criterion with no judge).
7
+ # criterion that could not be scored (an llm_judge criterion with no judge,
8
+ # or one the Scorer did not ask because the expectations failed).
8
9
  #
9
10
  # Criteria are `{ "key", "type", "config" }` hashes:
10
11
  #
@@ -22,41 +23,37 @@ module ActiveAgent
22
23
  class Scorer
23
24
  RULE_CRITERION_TYPES = %w[response_present min_length max_latency_ms token_budget contains not_contains].freeze
24
25
  CRITERION_TYPES = (RULE_CRITERION_TYPES + %w[llm_judge]).freeze
26
+ # The keys `score` adds from the scenario's own expectations.
27
+ EXPECTATION_KEYS = %w[expected_tools expected_content forbidden_content tools_succeeded].freeze
25
28
 
26
- attr_reader :criteria, :judge
29
+ attr_reader :criteria, :judge, :judge_failed_expectations
27
30
 
28
- def initialize(criteria: [], judge: nil)
31
+ # @param criteria [Array<Hash>] the criteria every replay is scored on
32
+ # @param judge [Judge, nil] scores the llm_judge criteria
33
+ # @param judge_failed_expectations [Boolean] whether an llm_judge criterion
34
+ # is asked of a replay that missed one of its scenario's expectations;
35
+ # false leaves it nil, since the replay fails on the expectation anyway
36
+ def initialize(criteria: [], judge: nil, judge_failed_expectations: true)
29
37
  @criteria = Array(criteria).map { |criterion| criterion.to_h.deep_stringify_keys }
30
38
  @judge = judge
39
+ @judge_failed_expectations = judge_failed_expectations
31
40
  end
32
41
 
33
42
  def score(scenario, replay)
34
43
  scores = {}
35
44
  answer = replay.answer.to_s
45
+ expectations = expectation_scores(scenario, replay)
46
+ ask_judge = answer.present? && (@judge_failed_expectations || self.class.expectations_met?(expectations))
36
47
 
37
48
  @criteria.each do |criterion|
38
- scores[criterion["key"]] = answer.present? ? score_criterion(criterion, scenario, replay) : 0.0
49
+ scores[criterion["key"]] =
50
+ if answer.blank? then 0.0
51
+ elsif criterion["type"] == "llm_judge" && !ask_judge then nil
52
+ else score_criterion(criterion, scenario, replay)
53
+ end
39
54
  end
40
55
 
41
- if scenario.expected_tools.any?
42
- scores["expected_tools"] = (scenario.expected_tools & replay.tool_names).any? ? 1.0 : 0.0
43
- end
44
- if scenario.expected_patterns.any?
45
- hits = scenario.expected_patterns.count { |pattern| self.class.matches_pattern?(answer, pattern) }
46
- scores["expected_content"] = (hits.to_f / scenario.expected_patterns.size).round(3)
47
- end
48
- if scenario.forbidden_patterns.any?
49
- hit = scenario.forbidden_patterns.any? { |pattern| self.class.matches_pattern?(answer, pattern) }
50
- scores["forbidden_content"] = hit ? 0.0 : 1.0
51
- end
52
- # A tool that ran without erroring is evidence only when it is one the
53
- # scenario expected: a wrong tool that succeeded used to outscore
54
- # calling nothing at all.
55
- if replay.tool_calls.any? && (scenario.expected_tools.empty? || (scenario.expected_tools & replay.tool_names).any?)
56
- scores["tools_succeeded"] = replay.failed_tool_calls.any? ? 0.0 : 1.0
57
- end
58
-
59
- scores
56
+ scores.merge(expectations)
60
57
  end
61
58
 
62
59
  # The mean of the scored criteria, or nil when nothing could be scored.
@@ -67,6 +64,12 @@ module ActiveAgent
67
64
  (scored.sum / scored.size).round(3)
68
65
  end
69
66
 
67
+ # Whether every expectation score in `scores` is a full 1.0. A scores
68
+ # hash with no expectation keys is met.
69
+ def self.expectations_met?(scores)
70
+ scores.slice(*EXPECTATION_KEYS).values.all? { |value| value.to_f >= 1.0 }
71
+ end
72
+
70
73
  # How long one pattern may take to match one answer. Patterns are
71
74
  # whatever the scenario's author typed, and a pathological one must not
72
75
  # stall the evaluation.
@@ -90,6 +93,33 @@ module ActiveAgent
90
93
 
91
94
  private
92
95
 
96
+ # The scores the scenario's own expectations add, in the order they appear
97
+ # after the criteria.
98
+ def expectation_scores(scenario, replay)
99
+ scores = {}
100
+ answer = replay.answer.to_s
101
+
102
+ if scenario.expected_tools.any?
103
+ scores["expected_tools"] = (scenario.expected_tools & replay.tool_names).any? ? 1.0 : 0.0
104
+ end
105
+ if scenario.expected_patterns.any?
106
+ hits = scenario.expected_patterns.count { |pattern| self.class.matches_pattern?(answer, pattern) }
107
+ scores["expected_content"] = (hits.to_f / scenario.expected_patterns.size).round(3)
108
+ end
109
+ if scenario.forbidden_patterns.any?
110
+ hit = scenario.forbidden_patterns.any? { |pattern| self.class.matches_pattern?(answer, pattern) }
111
+ scores["forbidden_content"] = hit ? 0.0 : 1.0
112
+ end
113
+ # A tool that ran without erroring is evidence only when it is one the
114
+ # scenario expected: a wrong tool that succeeded used to outscore
115
+ # calling nothing at all.
116
+ if replay.tool_calls.any? && (scenario.expected_tools.empty? || (scenario.expected_tools & replay.tool_names).any?)
117
+ scores["tools_succeeded"] = replay.failed_tool_calls.any? ? 0.0 : 1.0
118
+ end
119
+
120
+ scores
121
+ end
122
+
93
123
  def score_criterion(criterion, scenario, replay)
94
124
  config = criterion["config"] || {}
95
125
  answer = replay.answer.to_s
@@ -6,13 +6,30 @@ module ActiveAgent
6
6
  # layered over earlier ones: a scenario with a key already present replaces
7
7
  # it, a new key is appended to its group, and a new group is appended to the
8
8
  # suite. That is how an app keeps a shared suite and lets a deployment add or
9
- # reword questions.
9
+ # reword questions. A `pass` block, on the suite and on a group, declares
10
+ # the bars a run is held to (PassThresholds); later documents override it
11
+ # key by key at both levels. Named criteria lists layer the same way by
12
+ # criterion key, and `criteria("shared", "terse")` hands the chosen lists
13
+ # to Runner.new(criteria:).
10
14
  #
11
15
  # suite: support_desk
12
16
  # description: Questions the support team asks every week
17
+ # pass:
18
+ # threshold: 0.7
19
+ # min_pass_rate: 0.8
20
+ # criteria:
21
+ # shared:
22
+ # - key: cites_order
23
+ # type: contains
24
+ # config: { pattern: "ABC-" }
25
+ # terse:
26
+ # - key: short
27
+ # type: min_length
28
+ # config: { chars: 20 }
13
29
  # groups:
14
30
  # - key: open_tickets
15
31
  # name: Open tickets
32
+ # pass: { min_pass_rate: 1.0 }
16
33
  # scenarios:
17
34
  # - key: open_tickets_1
18
35
  # prompt: Which open tickets mention a refund?
@@ -23,7 +40,7 @@ module ActiveAgent
23
40
  class Suite
24
41
  class NotFound < StandardError; end
25
42
 
26
- attr_reader :name, :description, :groups
43
+ attr_reader :name, :description, :groups, :pass_thresholds
27
44
 
28
45
  # Loads the documents at `paths` (missing files are skipped) and raises
29
46
  # NotFound when none exist.
@@ -35,20 +52,75 @@ module ActiveAgent
35
52
  new(documents, name: name || File.basename(existing.first, ".yml"))
36
53
  end
37
54
 
55
+ # The scenarios among `scenarios` whose key is in `keys`, in the order of
56
+ # `scenarios`. `keys` is an Array of Strings or Symbols or one
57
+ # comma-separated String; blanks are dropped, a repeated key selects
58
+ # once, and blank `keys` leave `scenarios` unchanged. Every key must name
59
+ # a scenario in `known`, which may be wider than `scenarios` (several
60
+ # suites' scenarios together), else NotFound lists the missing keys in
61
+ # the order given, "in <name>" appended when `name` is given. The result
62
+ # is empty when `known` holds the keys but `scenarios` does not.
63
+ def self.select(scenarios, keys:, known: scenarios, name: nil)
64
+ keys = keys.to_s.split(",") unless keys.is_a?(Array)
65
+ keys = keys.map { |key| key.to_s.strip }.reject(&:empty?).uniq
66
+ return scenarios if keys.empty?
67
+
68
+ missing = keys - known.map(&:key)
69
+ if missing.any?
70
+ where = name.present? ? " in #{name}" : ""
71
+ raise NotFound, "no scenario #{missing.join(', ')}#{where}"
72
+ end
73
+
74
+ scenarios.select { |scenario| keys.include?(scenario.key) }
75
+ end
76
+
38
77
  # @param documents [Array<Hash>] parsed YAML documents, base first
39
78
  def initialize(documents, name: nil)
40
79
  documents = Array(documents).map(&:deep_stringify_keys)
41
80
  @name = documents.filter_map { |doc| doc["suite"] }.last || name
42
81
  @description = documents.filter_map { |doc| doc["description"] }.last
82
+ @pass_thresholds = PassThresholds.from_documents(documents)
43
83
  @groups = merge_groups(documents)
84
+ @criteria_lists = merge_criteria_lists(documents)
85
+ end
86
+
87
+ # The names of the suite's criteria lists, in document order.
88
+ def criteria_names
89
+ @criteria_lists.keys
90
+ end
91
+
92
+ # The criteria of the named lists in the order given, a name given twice
93
+ # contributing once, or of every list when no names are given. A
94
+ # criterion whose key an earlier list already holds replaces it in
95
+ # place, so an audience's list can restate a shared criterion with its
96
+ # own bar and the answer is scored on it once. A name no document
97
+ # declared contributes nothing, so a caller can ask for "shared" plus an
98
+ # audience that has no list of its own, and a nil or blank name is
99
+ # skipped, so an audience left unset narrows nothing. The result is
100
+ # ready for Runner.new(criteria:).
101
+ def criteria(*names)
102
+ names = names.flatten.filter_map { |list_name| list_name.to_s.presence }.uniq
103
+ names = criteria_names if names.empty?
104
+ names.each_with_object([]) do |list_name, chosen|
105
+ @criteria_lists.fetch(list_name, []).each do |criterion|
106
+ index = chosen.index { |existing| existing["key"] == criterion["key"] }
107
+ index ? chosen[index] = criterion : chosen << criterion
108
+ end
109
+ end
44
110
  end
45
111
 
46
112
  # Scenarios narrowed by group keys, scenario keys, or both; `production_only`
47
- # scenarios are dropped unless `include_production_only` is true.
48
- def scenarios(groups: nil, keys: nil, include_production_only: true)
113
+ # scenarios are dropped unless `include_production_only` is true. With
114
+ # `strict`, every key in `keys` must name a scenario of the suite (see
115
+ # Suite.select), and a key outside the chosen groups selects nothing.
116
+ def scenarios(groups: nil, keys: nil, include_production_only: true, strict: false)
49
117
  selected = all_scenarios
50
118
  selected = selected.select { |scenario| Array(groups).map(&:to_s).include?(scenario.group) } if groups.present?
51
- selected = selected.select { |scenario| Array(keys).map(&:to_s).include?(scenario.key) } if keys.present?
119
+ if strict
120
+ selected = Suite.select(selected, keys: keys, known: all_scenarios, name: name)
121
+ elsif keys.present?
122
+ selected = selected.select { |scenario| Array(keys).map(&:to_s).include?(scenario.key) }
123
+ end
52
124
  selected = selected.reject(&:production_only?) unless include_production_only
53
125
  selected
54
126
  end
@@ -56,7 +128,8 @@ module ActiveAgent
56
128
  def all_scenarios
57
129
  @all_scenarios ||= @groups.flat_map do |group|
58
130
  group["scenarios"].each_with_index.map do |entry, index|
59
- Scenario.from_hash(entry.merge("position" => index), group: group["key"], group_name: group["name"])
131
+ Scenario.from_hash(entry.merge("position" => index), group: group["key"], group_name: group["name"],
132
+ metadata: entry.except(*Scenario::DOCUMENT_KEYS, "group", "group_name"))
60
133
  end
61
134
  end
62
135
  end
@@ -80,6 +153,7 @@ module ActiveAgent
80
153
  if existing
81
154
  existing["name"] = incoming["name"] if incoming["name"].present?
82
155
  existing["description"] = incoming["description"] if incoming["description"].present?
156
+ existing["pass"] = (existing["pass"] || {}).merge(incoming["pass"]) if incoming["pass"].present?
83
157
  merge_scenarios(existing, Array(incoming["scenarios"]))
84
158
  else
85
159
  groups << incoming.merge("scenarios" => Array(incoming["scenarios"]))
@@ -94,6 +168,43 @@ module ActiveAgent
94
168
  index ? group["scenarios"][index] = scenario : group["scenarios"] << scenario
95
169
  end
96
170
  end
171
+
172
+ # Each document's criteria lists layered over the earlier ones by name
173
+ # and, within a name, by criterion key.
174
+ def merge_criteria_lists(documents)
175
+ documents.each_with_object({}) do |document, lists|
176
+ criteria_lists(document).each do |list_name, incoming|
177
+ list = (lists[list_name] ||= [])
178
+ incoming.each do |criterion|
179
+ index = list.index { |existing| existing["key"] == criterion["key"] }
180
+ index ? list[index] = criterion : list << criterion
181
+ end
182
+ end
183
+ end
184
+ end
185
+
186
+ # A document's `criteria` as `name => list`; a bare list is the one list
187
+ # named "default", and an empty one declares no list.
188
+ def criteria_lists(document)
189
+ value = document["criteria"]
190
+ return {} if value.nil? || value == []
191
+
192
+ value = { "default" => value } if value.is_a?(Array)
193
+ raise ArgumentError, "the criteria of suite #{name.inspect} are not a list or a mapping of lists" unless value.is_a?(Hash)
194
+
195
+ value.to_h do |list_name, list|
196
+ raise ArgumentError, "criteria list #{list_name.inspect} of suite #{name.inspect} is not a list" unless list.is_a?(Array)
197
+
198
+ [ list_name.to_s, list.map { |criterion| normalize_criterion(criterion, list_name) } ]
199
+ end
200
+ end
201
+
202
+ def normalize_criterion(criterion, list_name)
203
+ raise ArgumentError, "a criterion in list #{list_name.inspect} of suite #{name.inspect} is not a mapping" unless criterion.is_a?(Hash)
204
+ raise ArgumentError, "a criterion in list #{list_name.inspect} of suite #{name.inspect} has no key" if criterion["key"].blank?
205
+
206
+ criterion.merge("key" => criterion["key"].to_s)
207
+ end
97
208
  end
98
209
  end
99
210
  end