activeagent 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # One scenario replayed under one model: the Replay, its scores, and the
6
+ # diagnosis when it fell short.
7
+ #
8
+ # `status` is "errored" when the run raised, "failed" when a fault was
9
+ # assigned, and "passed" otherwise. `diagnosis` is the Diagnosis::Result
10
+ # hash, with a `"judge"` sub-hash when a Judge refined it.
11
+ Result = Struct.new(:scenario, :spec, :replay, :scores, :score, :status, :diagnosis, keyword_init: true) do
12
+ def passed?
13
+ status == "passed"
14
+ end
15
+
16
+ def failed?
17
+ status == "failed"
18
+ end
19
+
20
+ def errored?
21
+ status == "errored"
22
+ end
23
+
24
+ def label
25
+ spec.label
26
+ end
27
+
28
+ def model
29
+ spec.model
30
+ end
31
+
32
+ def provider
33
+ spec.provider
34
+ end
35
+
36
+ def fault
37
+ diagnosis && diagnosis["fault"]
38
+ end
39
+
40
+ def summary
41
+ diagnosis && diagnosis["summary"]
42
+ end
43
+
44
+ def recommendation
45
+ diagnosis && diagnosis["recommendation"]
46
+ end
47
+
48
+ def suggested_tool
49
+ diagnosis&.dig("judge", "suggested_tool")
50
+ end
51
+
52
+ def to_h
53
+ {
54
+ "scenario_key" => scenario.key,
55
+ "group" => scenario.group,
56
+ "prompt" => scenario.prompt,
57
+ "label" => label,
58
+ "provider" => provider,
59
+ "model" => model,
60
+ "status" => status,
61
+ "score" => score,
62
+ "scores" => scores,
63
+ "answer" => replay.answer,
64
+ "tool_calls" => replay.tool_calls,
65
+ "duration_ms" => replay.duration_ms,
66
+ "input_tokens" => replay.input_tokens,
67
+ "output_tokens" => replay.output_tokens,
68
+ "cost" => replay.cost,
69
+ "error" => replay.error,
70
+ "fault" => fault,
71
+ "recommendation" => recommendation,
72
+ "diagnosis" => diagnosis,
73
+ "metadata" => replay.metadata
74
+ }.compact
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # Replays every scenario under every candidate model, scores and diagnoses
6
+ # each answer, and returns a Report.
7
+ #
8
+ # The one thing the runner does not know is how to talk to your agent;
9
+ # `replay` is a callable `(scenario, model_spec) → Replay` (a Hash with the
10
+ # same keys is accepted, and an exception becomes an errored Replay). A
11
+ # scenario passes when its replay completed, met its expectations, and both
12
+ # its mean score and the mean of its judge grades (task completion, or the
13
+ # configured llm_judge criteria) reached `threshold`;
14
+ # anything else carries exactly one fault
15
+ # and a recommendation from Diagnosis, refined by the `judge` for the
16
+ # faults in `refine_faults` (up to `judge_limit` calls per run).
17
+ #
18
+ # Runner.new(
19
+ # scenarios: suite.scenarios(groups: %w[blame]),
20
+ # models: ModelSpec.parse_all(%w[gpt-5-mini claude-haiku-4-5], default_provider: "openai"),
21
+ # criteria: [{ "key" => "response_present", "type" => "response_present" }],
22
+ # available_tools: { "find_records" => "Look up records" },
23
+ # instructions: agent.instructions,
24
+ # judge: Judge.new(label: "claude-opus-5") { |instructions:, prompt:| ... },
25
+ # replay: ->(scenario, spec) { ... },
26
+ # on_result: ->(result) { persist(result) }
27
+ # ).call
28
+ class Runner
29
+ # Faults where a judge can add something the evidence alone cannot: what
30
+ # tool to add, or how to change the instructions.
31
+ DEFAULT_REFINE_FAULTS = %w[missing_capability expected_tool_not_called low_quality missing_content].freeze
32
+ DEFAULT_JUDGE_LIMIT = 25
33
+
34
+ attr_reader :scenarios, :models, :criteria, :judge, :threshold
35
+
36
+ # @param scenarios [Array<Scenario>]
37
+ # @param models [Array<ModelSpec>]
38
+ # @param replay [#call] `(scenario, model_spec) → Replay`
39
+ # @param criteria [Array<Hash>] Scorer criteria applied to every replay
40
+ # @param judge [Judge, nil]
41
+ # @param judge_task [Boolean] with a judge and no llm_judge criterion, also
42
+ # score task completion (`Judge#score_task`) as the `task_completion` criterion
43
+ # @param available_tools [Hash{String=>String}, Array<String>] the agent's tool roster
44
+ # @param instructions [String, nil] the agent's instructions, for the judge
45
+ # @param agent_name [String] how recommendations refer to the agent
46
+ # @param on_result [#call, nil] called with each Result as it lands
47
+ # @param around_evaluation [#call, nil] called with (scenario, spec) and a
48
+ # block that returns the Result. Establishes context for replay, scoring
49
+ # and recommendations; must return the block's result. Wrapper errors
50
+ # propagate to the caller. Applies to #call, not direct #evaluate calls.
51
+ # @param require_judge_scores [Boolean] fail an otherwise passing result
52
+ # when a requested task/LLM grade is unavailable, rather than falling
53
+ # back to rule scores. Does not require a judge for rules-only runs.
54
+ def initialize(scenarios:, models:, replay:, criteria: [], judge: nil, judge_task: true, available_tools: {},
55
+ instructions: nil, agent_name: "The agent", threshold: PASS_THRESHOLD,
56
+ refine_faults: DEFAULT_REFINE_FAULTS, judge_limit: DEFAULT_JUDGE_LIMIT, on_result: nil,
57
+ around_evaluation: nil, require_judge_scores: false, metadata: {})
58
+ @scenarios = scenarios
59
+ @models = models
60
+ @replay = replay
61
+ @criteria = criteria
62
+ @judge = judge
63
+ @judge_task = judge_task
64
+ @available_tools = normalize_tools(available_tools)
65
+ @instructions = instructions
66
+ @agent_name = agent_name
67
+ @threshold = threshold
68
+ @refine_faults = refine_faults
69
+ @judge_limit = judge_limit
70
+ @on_result = on_result
71
+ @around_evaluation = around_evaluation
72
+ @require_judge_scores = require_judge_scores
73
+ @metadata = metadata
74
+ @judge_calls = 0
75
+ @scorer = Scorer.new(criteria: criteria, judge: judge)
76
+ end
77
+
78
+ def call
79
+ results = @scenarios.flat_map do |scenario|
80
+ @models.map do |spec|
81
+ evaluate_with_context(scenario, spec).tap { |result| @on_result&.call(result) }
82
+ end
83
+ end
84
+
85
+ Report.new(results: results, models: @models, judge: @judge, instructions: @instructions,
86
+ threshold: @threshold, metadata: @metadata)
87
+ end
88
+
89
+ # Scores and diagnoses one replay. Public so a caller that has already run
90
+ # the agent (a background job per scenario, say) can score without the loop.
91
+ def evaluate(scenario, spec, replay = nil)
92
+ replay ||= run_replay(scenario, spec)
93
+ scores = @scorer.score(scenario, replay)
94
+ if judge_task? && replay.answer.present?
95
+ scores["task_completion"] = @judge.score_task(scenario: scenario, answer: replay.answer)
96
+ end
97
+ score = Scorer.mean(scores)
98
+
99
+ diagnosis = Diagnosis.call(scenario: scenario, replay: replay, scores: scores, score: score,
100
+ available_tools: @available_tools.keys, threshold: @threshold, agent_name: @agent_name,
101
+ judge_keys: llm_judge_keys)
102
+ diagnosis ||= unavailable_judge_diagnosis(scores)
103
+ diagnosis_hash = diagnosis&.to_h
104
+ refine!(diagnosis_hash, scenario, replay, diagnosis) if diagnosis_hash
105
+
106
+ Result.new(
107
+ scenario: scenario,
108
+ spec: spec,
109
+ replay: replay,
110
+ scores: scores,
111
+ score: score,
112
+ status: replay.errored? ? "errored" : (diagnosis ? "failed" : "passed"),
113
+ diagnosis: diagnosis_hash
114
+ )
115
+ end
116
+
117
+ private
118
+
119
+ def evaluate_with_context(scenario, spec)
120
+ return evaluate(scenario, spec) unless @around_evaluation
121
+
122
+ result = @around_evaluation.call(scenario, spec) { evaluate(scenario, spec) }
123
+ # A wrapper written the natural way — do something, yield, do something
124
+ # after — returns that last value rather than the Result. Left alone it
125
+ # reaches on_result and the Report, and fails somewhere far from the
126
+ # wrapper that caused it. Name the wrapper here instead.
127
+ unless result.is_a?(Result)
128
+ raise ArgumentError, "around_evaluation must return the Result its block yields, got #{result.class}"
129
+ end
130
+
131
+ result
132
+ end
133
+
134
+ def judge_task?
135
+ @judge && @judge_task && @criteria.none? { |criterion| criterion.to_h.stringify_keys["type"] == "llm_judge" }
136
+ end
137
+
138
+ # The keys in `scores` a judge graded, so a low grade is not averaged
139
+ # away against rule checks.
140
+ def llm_judge_keys
141
+ @llm_judge_keys ||= @criteria.filter_map do |criterion|
142
+ value = criterion.to_h.stringify_keys
143
+ value["key"] if value["type"] == "llm_judge"
144
+ end
145
+ end
146
+
147
+ def unavailable_judge_diagnosis(scores)
148
+ return unless @require_judge_scores
149
+
150
+ keys = llm_judge_keys.dup
151
+ keys << "task_completion" if judge_task?
152
+ missing = keys.select { |key| scores[key].nil? }
153
+ return if missing.empty?
154
+
155
+ Diagnosis::Result.new(
156
+ fault: "judge_unavailable",
157
+ summary: "The evaluation judge did not return a usable score for #{missing.join(', ')}.",
158
+ recommendation: "Check the judge's credentials, model availability and JSON response, then re-run this evaluation. " \
159
+ "The available rule scores do not establish answer quality.",
160
+ evidence: { "unscored_criteria" => missing }
161
+ )
162
+ end
163
+
164
+ # Whatever the callable raises becomes an errored Replay, so one model
165
+ # rejecting a parameter fails its scenario rather than the whole run.
166
+ # A return value that is neither a Replay nor a Hash is the caller's
167
+ # bug and raises.
168
+ def run_replay(scenario, spec)
169
+ value =
170
+ begin
171
+ @replay.call(scenario, spec)
172
+ rescue StandardError => e
173
+ return Replay.failed(e)
174
+ end
175
+
176
+ case value
177
+ when Replay then value
178
+ when Hash then Replay.new(**value.to_h.symbolize_keys)
179
+ else raise ArgumentError, "replay must return an ActiveAgent::Evals::Replay or a Hash, got #{value.class}"
180
+ end
181
+ end
182
+
183
+ def refine!(diagnosis, scenario, replay, result)
184
+ return unless @judge && @refine_faults.include?(result.fault)
185
+ return if @judge_calls >= @judge_limit
186
+
187
+ @judge_calls += 1
188
+ refined = @judge.recommend(scenario: scenario, replay: replay, diagnosis: result,
189
+ available_tools: @available_tools, instructions: @instructions)
190
+ return unless refined
191
+
192
+ diagnosis["judge"] = refined
193
+ diagnosis["recommendation"] = refined["recommendation"].to_s.strip if refined["recommendation"].present?
194
+ end
195
+
196
+ def normalize_tools(tools)
197
+ case tools
198
+ when Hash then tools.to_h { |name, description| [ name.to_s, description.to_s ] }
199
+ else Array(tools).to_h { |tool| tool.respond_to?(:name) ? [ tool.name.to_s, (tool.respond_to?(:description) ? tool.description.to_s : "") ] : [ tool.to_s, "" ] }
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # One task an evaluation replays through the agent: the message a user
6
+ # would send, the group of related tasks it belongs to, and what a passing
7
+ # answer is expected to do.
8
+ #
9
+ # @!attribute key
10
+ # @return [String] stable within a suite ("blame_3"), so results line up across runs
11
+ # @!attribute group
12
+ # @return [String, nil] the group key ("blame")
13
+ # @!attribute group_name
14
+ # @return [String, nil] the group's display name ("Blame / audit")
15
+ # @!attribute expected_tools
16
+ # @return [Array<String>] tool names a passing answer calls (any one of them)
17
+ # @!attribute expected_patterns
18
+ # @return [Array<String>] substrings or patterns the answer must contain
19
+ # @!attribute forbidden_patterns
20
+ # @return [Array<String>] substrings or patterns the answer must avoid
21
+ # @!attribute production_only
22
+ # @return [Boolean] whether a local environment cannot answer with real data
23
+ Scenario = Struct.new(:key, :group, :group_name, :prompt, :expected_tools, :expected_patterns,
24
+ :forbidden_patterns, :notes, :production_only, :position, keyword_init: true) do
25
+ # Builds a scenario from a hash — ScenarioParser output, a suite entry, or
26
+ # a persisted record's attributes. Expectations are read from an
27
+ # `expectations`/`expect` sub-hash (`tools`, `contains`, `not_contains`)
28
+ # or from those keys at the top level.
29
+ def self.from_hash(attributes, group: nil, group_name: nil)
30
+ attrs = attributes.to_h.deep_stringify_keys
31
+ expect = (attrs["expectations"] || attrs["expect"] || {}).to_h.stringify_keys
32
+
33
+ new(
34
+ key: attrs["key"].to_s,
35
+ group: attrs["group"].presence || group,
36
+ group_name: attrs["group_name"].presence || group_name,
37
+ prompt: attrs.fetch("prompt").to_s.strip,
38
+ expected_tools: list(expect["tools"] || attrs["tools"]),
39
+ expected_patterns: list(expect["contains"] || attrs["contains"]),
40
+ forbidden_patterns: list(expect["not_contains"] || attrs["not_contains"]),
41
+ notes: attrs["notes"].presence,
42
+ production_only: attrs["production_only"] == true,
43
+ position: attrs["position"]
44
+ )
45
+ end
46
+
47
+ def self.list(value)
48
+ Array(value).map(&:to_s).reject(&:blank?)
49
+ end
50
+
51
+ def production_only?
52
+ production_only == true
53
+ end
54
+
55
+ def expectations
56
+ {
57
+ "tools" => expected_tools,
58
+ "contains" => expected_patterns,
59
+ "not_contains" => forbidden_patterns
60
+ }.reject { |_, value| value.blank? }
61
+ end
62
+
63
+ def to_h
64
+ super.compact
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # Turns a pasted list of user messages into scenario attributes. Accepts the
6
+ # shapes people actually paste:
7
+ #
8
+ # - one message per line, with or without list markers (`-`, `*`, `1.`)
9
+ # - `# Heading` or `**Heading**` lines, which start a group; so does a
10
+ # short unmarked line ending in a colon (`Find records:`)
11
+ # - a message in backticks at the start of the line, followed by notes,
12
+ # as in an issue's question catalog:
13
+ # `` 3. `Show me all providers with no license on file` — 1,060 locally ``
14
+ # - trailing ` | tools: a, b | contains: x | not_contains: y | key: k`
15
+ # options on a line
16
+ # - a JSON array of strings, or of objects with `prompt` (or `message`),
17
+ # `group`, `key`, `notes`, `tools`, `contains`, `not_contains`
18
+ # - a grouped Suite document in YAML or JSON, retaining its expectations,
19
+ # group names, stable keys, notes and production-only flags
20
+ #
21
+ # Every scenario gets a key unique within the paste, derived from its group
22
+ # and position ("blame_3"), unless the line names one. The result is an
23
+ # array of string-keyed hashes; `Scenario.from_hash` builds the structs.
24
+ class ScenarioParser
25
+ class ParseError < ArgumentError; end
26
+
27
+ LIST_MARKER = /\A\s*(?:[-*•]|\d+[.)])\s+/
28
+ HEADING = /\A\s*#+\s+(.+?)\s*\z/
29
+ BOLD_HEADING = /\A\s*\*\*(.+?)\*\*:?\s*(?:—.*)?\z/
30
+ # Only a backticked span that opens the line is the prompt; a message
31
+ # that merely mentions `some_tool` is kept whole.
32
+ BACKTICK_PROMPT = /\A`([^`]+)`/
33
+ OPTION_KEYS = %w[tools contains not_contains key group notes].freeze
34
+
35
+ def self.parse(text, include_production_only: true)
36
+ new(text).parse(include_production_only: include_production_only)
37
+ end
38
+
39
+ # Parses and builds Scenario structs in one step.
40
+ def self.scenarios(text, include_production_only: true)
41
+ parse(text, include_production_only: include_production_only).map { |attrs| Scenario.from_hash(attrs) }
42
+ end
43
+
44
+ def initialize(text)
45
+ @text = text.to_s
46
+ end
47
+
48
+ # @return [Array<Hash>] scenario attributes with string keys
49
+ def parse(include_production_only: true)
50
+ stripped = @text.strip
51
+ return [] if stripped.empty?
52
+
53
+ scenarios = if json?(stripped)
54
+ parse_json(stripped)
55
+ elsif stripped.match?(/^(?:suite|groups):(?:\s|$)/)
56
+ parse_suite_yaml(stripped)
57
+ else
58
+ parse_lines(stripped)
59
+ end
60
+ assigned = assign_keys(scenarios)
61
+ include_production_only ? assigned : assigned.reject { |entry| entry["production_only"] }
62
+ end
63
+
64
+ private
65
+
66
+ def json?(text)
67
+ text.start_with?("[", "{")
68
+ end
69
+
70
+ def parse_json(text)
71
+ parsed = JSON.parse(text)
72
+ return parse_suite(parsed) if parsed.is_a?(Hash) && parsed.key?("groups")
73
+
74
+ parsed = parsed["scenarios"] if parsed.is_a?(Hash) && parsed.key?("scenarios")
75
+ parsed = [ parsed ] if parsed.is_a?(Hash)
76
+
77
+ Array(parsed).filter_map do |entry|
78
+ case entry
79
+ when String then scenario(prompt: entry)
80
+ when Hash then scenario_from_hash(entry)
81
+ end
82
+ end
83
+ rescue JSON::ParserError
84
+ parse_lines(text)
85
+ end
86
+
87
+ def parse_suite_yaml(text)
88
+ document = YAML.safe_load(text, aliases: true)
89
+ raise ParseError, "evaluation suite must contain a groups array" unless document.is_a?(Hash) && document["groups"].is_a?(Array)
90
+
91
+ parse_suite(document)
92
+ rescue Psych::Exception => e
93
+ raise ParseError, "invalid evaluation suite YAML: #{e.message}"
94
+ end
95
+
96
+ def parse_suite(document)
97
+ raise ParseError, "evaluation suite must contain a groups array" unless document["groups"].is_a?(Array)
98
+
99
+ document["groups"].each do |group|
100
+ unless group.is_a?(Hash) && (group["scenarios"].nil? || group["scenarios"].is_a?(Array))
101
+ raise ParseError, "each evaluation group must contain a scenarios array"
102
+ end
103
+ Array(group["scenarios"]).each do |entry|
104
+ unless entry.is_a?(Hash) && entry["prompt"].is_a?(String) && entry["prompt"].present?
105
+ raise ParseError, "each evaluation scenario must contain a prompt"
106
+ end
107
+ expectations = entry["expectations"] || entry["expect"]
108
+ if expectations && !expectations.is_a?(Hash)
109
+ raise ParseError, "scenario expectations must be an object"
110
+ end
111
+ end
112
+ end
113
+
114
+ Suite.new([ document ]).all_scenarios.map do |item|
115
+ scenario(prompt: item.prompt, group: item.group, group_name: item.group_name, key: item.key,
116
+ notes: item.notes, expectations: item.expectations, production_only: item.production_only?)
117
+ end
118
+ end
119
+
120
+ def scenario_from_hash(entry)
121
+ entry = entry.stringify_keys
122
+ prompt = entry["prompt"] || entry["message"] || entry["input"] || entry["question"]
123
+ return nil if prompt.blank?
124
+
125
+ expectations = (entry["expectations"] || entry["expect"] || {}).to_h.stringify_keys
126
+ %w[tools contains not_contains].each do |field|
127
+ expectations[field] = Array(entry[field]) if entry.key?(field)
128
+ end
129
+
130
+ scenario(
131
+ prompt: prompt,
132
+ group: entry["group"],
133
+ group_name: entry["group_name"],
134
+ key: entry["key"],
135
+ notes: entry["notes"],
136
+ expectations: expectations,
137
+ production_only: entry["production_only"] == true
138
+ )
139
+ end
140
+
141
+ def parse_lines(text)
142
+ group = nil
143
+ scenarios = []
144
+
145
+ text.each_line do |raw|
146
+ line = raw.strip
147
+ next if line.empty?
148
+
149
+ if (heading = heading_for(line))
150
+ group = heading
151
+ next
152
+ end
153
+
154
+ scenarios << parse_line(line, group)
155
+ end
156
+
157
+ scenarios
158
+ end
159
+
160
+ def heading_for(line)
161
+ return Regexp.last_match(1).strip if line =~ HEADING
162
+ return strip_markup(Regexp.last_match(1)) if line =~ BOLD_HEADING
163
+ return line.chomp(":").strip if colon_heading?(line)
164
+
165
+ nil
166
+ end
167
+
168
+ # `Find records:` reads as a heading; a question, or a line carrying
169
+ # `| options`, does not, however it ends.
170
+ def colon_heading?(line)
171
+ line.end_with?(":") && line.length <= 80 && !line.match?(LIST_MARKER) &&
172
+ !line.include?("?") && !line.include?(" | ")
173
+ end
174
+
175
+ def parse_line(line, group)
176
+ body = line.sub(LIST_MARKER, "")
177
+ body, options = split_options(body)
178
+
179
+ if (match = body.match(BACKTICK_PROMPT))
180
+ prompt = match[1].strip
181
+ notes = body.sub(match[0], "").sub(/\A\s*[—–-]\s*/, "").strip.presence
182
+ else
183
+ prompt = strip_markup(body)
184
+ notes = nil
185
+ end
186
+
187
+ scenario(
188
+ prompt: prompt,
189
+ group: options["group"].presence || group,
190
+ key: options["key"],
191
+ notes: [ notes, options["notes"] ].compact.join(" ").presence,
192
+ expectations: options.slice("tools", "contains", "not_contains").transform_values { |value| split_list(value) }
193
+ )
194
+ end
195
+
196
+ # `prompt | tools: a, b | contains: x` → [prompt, { "tools" => "a, b", ... }]
197
+ def split_options(body)
198
+ segments = body.split(/\s+\|\s+/)
199
+ return [ body, {} ] if segments.size == 1
200
+
201
+ options = {}
202
+ rest = [ segments.shift ]
203
+ segments.each do |segment|
204
+ key, value = segment.split(":", 2)
205
+ if value && OPTION_KEYS.include?(key.strip.downcase)
206
+ options[key.strip.downcase] = value.strip
207
+ else
208
+ rest << segment
209
+ end
210
+ end
211
+
212
+ [ rest.join(" | "), options ]
213
+ end
214
+
215
+ def split_list(value)
216
+ value.to_s.split(/\s*[,;]\s*/).map(&:strip).reject(&:blank?)
217
+ end
218
+
219
+ def strip_markup(text)
220
+ text.to_s.gsub(/\*\*|__|`/, "").strip
221
+ end
222
+
223
+ def scenario(prompt:, group: nil, group_name: nil, key: nil, notes: nil, expectations: {}, production_only: false)
224
+ {
225
+ "prompt" => prompt.to_s.strip,
226
+ "group" => group.presence&.to_s&.strip,
227
+ "group_name" => group_name.presence&.to_s&.strip,
228
+ "key" => key.presence&.to_s&.strip,
229
+ "notes" => notes.presence,
230
+ "expectations" => (expectations || {}).reject { |_, value| value.blank? },
231
+ "production_only" => production_only
232
+ }
233
+ end
234
+
235
+ # A key named on a line is kept; a generated one never collides with a
236
+ # named key anywhere in the paste; and a named key that repeats an
237
+ # earlier line's is treated as missing, so no two scenarios share one.
238
+ def assign_keys(scenarios)
239
+ named = scenarios.filter_map { |s| s["key"].presence }.to_set
240
+ taken = Set.new
241
+ counters = Hash.new(0)
242
+
243
+ scenarios.each_with_index do |scenario, index|
244
+ scenario["position"] = index
245
+ key = scenario["key"].presence
246
+ key = nil if key && taken.include?(key)
247
+
248
+ unless key
249
+ base = scenario["group"].to_s.parameterize(separator: "_").first(30).presence || "scenario"
250
+ loop do
251
+ counters[base] += 1
252
+ key = "#{base}_#{counters[base]}"
253
+ break unless named.include?(key) || taken.include?(key)
254
+ end
255
+ end
256
+
257
+ taken << key
258
+ scenario["key"] = key
259
+ end
260
+
261
+ scenarios
262
+ end
263
+ end
264
+ end
265
+ end